diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index 24f76c7cb0..30244c0085 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -6,14 +6,14 @@ en: 41f30d473ff53ce2 zh: 6851c4414f5331c1 /agent-note-session-projections-as-a-required-reader-seam/problem: - en: c0c779c717cad546 - zh: 5d2916ac86bf5d84 + en: fca00eeb7a8d9abd + zh: d7a9766f5322dff9 /agent-note-session-projections-as-a-required-reader-seam/decision: - en: a1c50d2f43334a88 - zh: d7736994733454b4 + en: 8d38ed9356085c7e + zh: e15ede51415192f3 /agent-note-session-projections-as-a-required-reader-seam/alternatives-considered: en: aea3da939c90d240 zh: 21e91c481cdd23b7 /agent-note-session-projections-as-a-required-reader-seam/consequences: - en: 73325513ec70fbb2 - zh: 610bf34b9f9db420 + en: ab45eb87f7c22cdb + zh: e65a97991a45164f diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md index e1c6835311..091e8b277a 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md @@ -6,19 +6,17 @@ English | [中文](2026-08-19-session-projection-mandatory-seam.zh.md) ## Problem -An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. Some contributor sites intentionally retain optional `ctx.inject` registration, so their readers need an explicit missing-state rule. +An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. ## Decision This decision builds on the split between host projection state and client views in [Session projection state and client views](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). -Every host reader treats the projection registry and its required key as mandatory state. A plugin either declares `sessionProjections` as a required injection or resolves the registry and key explicitly and throws on the first dependent access. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. +Every host state owner treats the projection registry and its required key as mandatory state. Owners declare `sessionProjections` as a required injection and await acquisition before exposing service methods; other consumers depend on those services. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. -A domain contributor may register its unit through `ctx.inject(['sessionProjections'], ...)`. Optional registration controls the child lifecycle only; it does not authorize a reader to substitute a default when the registry or key is absent. +Owners acquire state through [awaited registration and retained handles](2026-10-12-session-projection-registration-handles.md). That decision supersedes optional first-access acquisition and returning a bare disposer. It preserves the required-state rule here: a missing registry cannot silently produce default host state. Client views still contain only consumed fields; business service methods expose the host data each consumer needs. -The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state. - -`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; `register()` returns the exact Cordis disposer so a composite domain owner can finish cleanup against projected state before removing its unit. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. +`onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. ## Alternatives considered @@ -30,6 +28,6 @@ The registry provides `stateOf(session, key)` for one typed host state and keeps ## Consequences -- Missing projection composition fails either during activation or at the first dependent host access; it never degrades to a default value. +- Missing projection composition fails during activation; it never degrades to a default value. - Host consumers avoid repeated whole-registry snapshots and log scans. - Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index 5d6a5169a5..c30f7bd35d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -6,19 +6,17 @@ Status: implemented ## 问题 -可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。部分贡献位置有意保留可选的 `ctx.inject` 注册形式,因此其读取方需要明确的缺失状态规则。 +可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。 ## 决策 本决策建立在[会话投影的 host 状态与客户端视图](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 -每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件要么把 `sessionProjections` 声明为必需注入,要么显式解析注册表与 key,并在第一次依赖它们的访问时抛错。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 +每个 host 状态所有者都把投影注册表及其所需 key 视为必需状态。所有者将 `sessionProjections` 声明为必需注入,并在提供服务方法前等待获取完成;其他消费方依赖这些服务。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 -领域贡献方可以通过 `ctx.inject(['sessionProjections'], ...)` 注册单元。可选注册只控制子功能生命周期;它并不允许读取方在注册表或 key 缺失时替换为默认值。 +所有者通过[等待注册与保留句柄](2026-10-12-session-projection-registration-handles.zh.md)获取状态。该决策取代可选首次访问获取和返回裸释放函数的方式,保留这里的必需状态规则:缺少注册表不能静默产生默认 host 状态。客户端视图仍只包含消费字段;业务服务方法提供各消费方所需的 host 数据。 -注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。 - -`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;`register()` 返回 Cordis 的原始 disposer,使组合式领域 owner 可以先依据投影状态完成清理,再移除自身单元。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 +`onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 ## 考虑过的替代方案 @@ -30,6 +28,6 @@ Status: implemented ## 后果 -- 缺少投影组合会在激活期间或第一次依赖它的 host 访问时失败,绝不会降级为默认值。 +- 缺少投影组合会在激活期间失败,绝不会降级为默认值。 - host 消费方避免重复的全注册表快照和日志扫描。 - 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index d1e00c2cd5..511e290621 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -9,8 +9,8 @@ en: edefff7cd986a509 zh: a270839fa86976a2 /agent-note-guided-plugin-installation/decision: - en: 30f1e636973df06a - zh: 0f874b88a5d4d7f5 + en: 5448b9f8aaa9a569 + zh: 9024c5328c5da3bd /agent-note-guided-plugin-installation/alternatives-considered: en: be64b541f4d81e7c zh: 8600531c08ab4288 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index c0e9e68325..40cbf6179b 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -20,7 +20,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for. -**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed. +**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added; the dialog closes, and the list scrolls to it when its card is on the current page. Nothing is enabled before the person has seen what was installed. **Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index 0d914869a3..c53c138059 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -20,7 +20,7 @@ Status: implemented **取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。 -**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。 +**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭;其卡片在当前页面时,列表滚动到它。在人看到装了什么之前,不会启用任何东西。 **当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml index 448629299d..5d8a536a53 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml @@ -9,11 +9,11 @@ en: 2ba6c43b20a3d347 zh: 5647179ddaa38ad9 /agent-note-ship-experimental-capabilities-as-optional-bundles/decision: - en: 6c8583bdefc5e36c - zh: 0928b4f2876bbfe4 + en: d6328ba2b218f2ac + zh: 3731561313b1ad5e /agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered: en: d6f40bf02df37ab7 zh: e5b85ce9d41e8754 /agent-note-ship-experimental-capabilities-as-optional-bundles/consequences: - en: 38d27583533cd028 - zh: 9bf11f5489c1aa43 + en: 3064da835e892958 + zh: bfcf967b6acc6f85 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md index dbdd104da0..a2d39b0ffb 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md @@ -12,12 +12,14 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A `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. -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. Browser and computer 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. +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. Bundles insert ordinary Host rows; optional model tools register in the global tool layer, so every preset, including minimal, receives them. Plugin management, profile composition, and reload need no bundle-specific behavior. Search, Ralph, and terminals supply their own isolated services in a Host group. Search opens one in-memory index on demand; the Host search policy stays unchanged. Badge skills and title providers act on the Host. Switching a bundle registers or removes its tools for live Agents, and later user patch layers retain precedence. A replacement title provider may register once the previous provider's disposal starts; the previous provider's late results never commit. Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES` catalog; the [on-demand native-bundle decision](2026-10-05-official-on-demand-bundles.md) owns the cost, version, and offline-discovery trade-offs. +Computer Use is a shipped optional bundle in the experimental group. Its bundle uses the explicit naming exception `@deepseek-ai/dsh-cua-bundle`, alongside `@deepseek-ai/dsh-cua`. These names preserve profile references when the capability moves package groups; the experimental classification still controls isolation and promotion. Its repository-owned native component is prepared when the provider loads, without an external Cua Driver runtime. + ## Alternatives considered **Ship every provider as an optional bundle.** Composes and displays correctly, but grows every install by provider runtimes for a capability most installations never switch on; voice input's `sherpa-onnx-node` is the one accepted precedent. @@ -31,3 +33,5 @@ Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES` ## Consequences Official discovery does not imply experimental status or a different plugin lifecycle. Switching a bundle off keeps its installed files; failed activation retains the existing saved-selection behavior. Bundle rows are ordinary Host rows with their usual switches. Individual and combined composition tests cover the Host rows and every preset's tool catalog; live switching and recorded Sessions cover activation and model-visible behavior. + +Computer Use keeps its permission service and native provider on the Host and registers tools in Agent scopes whose bound preset is `ptc`. Existing Agent events and `createScope()` maintain current, new and resumed Agents. Preset declarations remain unchanged. Only the explicit detail-page action changes the new-session default through the existing settings form; the activation switch does not configure PTC execution. diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md index 6c9df974e8..15bc65b56f 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md @@ -12,12 +12,14 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r `OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;官方分组显示这些元数据。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。 -可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。浏览器与电脑提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。 +可选 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 特有的激活行为。 Bundle 插入普通 Host 行;可选模型工具注册到全局工具层,因此包括 minimal 在内的所有预设都会获得它们。插件管理、profile 组合与重载无需针对 bundle 的特殊行为。搜索、Ralph 与终端在 Host 组内提供自己的隔离服务。搜索按需打开一个内存索引;Host 搜索策略保持不变。徽章技能与标题提供方作用于 Host。切换 bundle 会为运行中的 Agent 注册或移除其工具,后续用户 patch 层继续优先。前一个标题提供方开始清理后,替代提供方即可注册;前一个提供方的迟到结果不会提交。 Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装,其成本、版本与离线发现取舍由[按需原生 bundle 决策](2026-10-05-official-on-demand-bundles.zh.md)管理。 +Computer Use 是 experimental 包组内随安装提供的可选组合包。组合包与 `@deepseek-ai/dsh-cua` 一起采用显式命名例外 `@deepseek-ai/dsh-cua-bundle`,在能力移动分组时保留配置引用。依赖隔离和晋升仍由实验性分类约束。提供方加载时准备仓库自有的本机组件,无需外部 Cua Driver 运行时。 + ## 考虑过的替代方案 **把所有提供方都做成可选 bundle。** 组合与展示都正确,但会为多数安装从不启用的能力把提供方运行时塞进每次安装;语音输入的 `sherpa-onnx-node` 是唯一被接受的先例。 @@ -31,3 +33,5 @@ Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装, ## 后果 官方发现不表示实验性状态,也不引入不同的插件生命周期。关闭 bundle 保留已安装文件;激活失败保留现有的已保存选择行为。Bundle 行是普通 Host 行,使用常规开关。独立与组合测试覆盖 Host 行及每个预设的工具目录;实时切换与已记录 Session 覆盖激活和模型可见行为。 + +Computer Use 将权限服务和原生提供方保留在 Host,在已绑定 `ptc` preset 的 Agent 作用域中注册工具。现有 Agent 事件和 `createScope()` 维护当前、新建和恢复的 Agent,preset 声明保持不变。只有详情页的显式操作通过现有设置表单修改新会话默认模式,启用开关不配置 PTC 执行方式。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml new file mode 100644 index 0000000000..46925831d8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-24-shared-time-vocabulary.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-09-24-shared-time-vocabulary.md +/agent-note-shared-time-vocabulary-for-wall-clock-selectors: + en: ae04747237709d0c + zh: a3731d98d5e940f9 +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/problem: + en: abd0263d14861926 + zh: e1736cd08e87f795 +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: + en: 52b45f1ac62e65ab + zh: 7f83cd4fb8dd09a6 +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: + en: 1737484c099fab52 + zh: 303b144c52d167bd +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: + en: 6ec74e7ac065bd5e + zh: 04d029059dea732b diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md new file mode 100644 index 0000000000..607dc68b7a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md @@ -0,0 +1,51 @@ +# Agent Note: Shared time vocabulary for wall-clock selectors + +Status: implemented + +English | [中文](2026-09-24-shared-time-vocabulary.zh.md) + +## Problem + +`dsh-util-time` existed for one job and one caller shape: `canonicalClientTimeZone` validated a caller-reported zone at a wire boundary and answered `undefined`, which `dsh-api-session-controller` and `dsh-subagent` mapped onto their own domain errors. Two other consumers had grown parts of the same vocabulary privately. Schedule held a strict RFC 3339 instant parser, a local-calendar parser, a four-digit-year bound and canonical formatter, a local wall-clock resolver, and its own IANA zone canonicalizer in `packages/schedule/schedule/src/domain.ts`, throwing `ScheduleInputError` instead of answering a result. `dsh-time-context` held a second copy of the IANA zone profile in `packages/context/time-context/src/request-zone.ts`, with its own regular expression and its own `Intl.DateTimeFormat` canonicalization, to enforce the stricter policy that a request may only name a zone another process resolves back to the same identity. The IANA profile and the `Intl` canonicalization step therefore existed three times in all, so a change to that profile had to be made and verified once per copy, and the next consumer of a wall-clock selector had no shared entry point to reach for. + +## Decision + +`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds the parsers, `src/duration.ts` holds the span type, and `src/index.ts` re-exports both and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume. + +The library answers discriminated results and carries no failure vocabulary. `parseOffsetInstant`, `fourDigitYearEpoch`, `parseLocalCalendar`, and `canonicalizeTimeZone` answer an `accepted` arm or a `rejected` arm naming a stable reason (`not_rfc3339`, `out_of_range`, `not_a_calendar_date`, `invalid_numeric_offset`, `malformed`, `blank`, `padded`, `not_area_location`, `unsupported`, `not_canonical_shape`). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. `formatFourDigitYearInstant` returns the canonical instant text or `undefined` outside the bound, `isFourDigitYearInstant` answers whether an epoch is representable at all, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is `out_of_range` in both, so year 0 is refused by `parseOffsetInstant` and `parseLocalCalendar` alike, while `resolveLocalInstant` accepts any year a real `Temporal` date carries because its callers derive local dates at the UTC edges. `LocalCalendarTime.year` records that split. + +Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they now call the shared parser and map its reason. Schedule still owns the durable record shapes, the fold and transition authority, the injection-resistant framing, and all recurrence arithmetic: the cron engine, the daily and weekly date walks, the latest-due search, and the schedule-specific accepted-decision bound. + +`dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers. + +`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is `fromMilliseconds` and a whole-part `decompose()`, the two entries the migrated consumer calls. + +The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads. + +Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. + +## Alternatives considered + +**Keep the shared module private to Schedule.** A Schedule-local `time` module beside `domain.ts` would shrink that file without touching a published package, its lockfile entry, its README, or its dependency set. It was rejected because the duplication this change exists to remove is the duplicate *profile*, not the duplicate line count: the zone regular expression, the canonical `Intl` step, and the instant profile would still exist twice, and the next boundary that accepts a wall-clock selector would copy a third. + +**Give the library its own error type.** A `TimeInputError` carrying the reason would let callers rethrow instead of mapping, shortening each call site. It was rejected because `util/time`'s existing contract is that each boundary owns its refusal vocabulary, and Schedule's messages reach the model through the tool result `{ code, message }` and the client's message card; a shared error class would put one caller's wording into another caller's surface. + +**Have the library return `undefined` instead of reasons.** That is the shape `canonicalClientTimeZone` already has, and it was the smaller change. It was rejected because Schedule must distinguish a malformed instant from a non-existent calendar date from an out-of-range clock field to keep its existing diagnostics, and `canonicalizeTimeZone` must distinguish a bad name from a locally unresolvable one; collapsing those to one falsy value would have forced Schedule to re-derive the distinction with its own regular expressions, restoring the duplication. + +**Brand the millisecond count instead of building a class.** `Branded` already exists in this repository for opaque identities, and a branded count is smaller than a class. It was rejected because the brand does not survive the operation that matters: TypeScript accepts `brandedA + brandedB` and answers an unbranded `number`, so a caller who adds a span to an epoch silently loses the unit, which is the exact mistake the type exists to prevent. A class makes that addition a type error, and it also gives the whole-part split one home. + +**Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name. + +**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need. + +## Consequences + +The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's `domain.ts` loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the `Intl` canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner. + +The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message. + +A value type publishes what its consumers call, so the span exposes the constructor and the split that `dsh-time-context` uses and nothing else. A conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it; the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the spans that will ask for a second-unit conversion, and the bound and the freeze the class already carries are what make that addition a small one. The package README records the un-migrated boundaries as a known limitation. + +The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). Two samples do change: `NaN` rendered `NaNs` and `+Infinity` rendered `Infinityd NaNs`, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built. + +Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch. diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md new file mode 100644 index 0000000000..838f6b9d4b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 面向墙钟选择器的共享时间词汇 + +Status: implemented + +[English](2026-09-24-shared-time-vocabulary.md) | 中文 + +## Problem + +`dsh-util-time` 此前只承担一项职责、面向一种调用方:`canonicalClientTimeZone` 在协议边界校验调用方所报时区并回答 `undefined`,由 `dsh-api-session-controller` 与 `dsh-subagent` 映射成各自的域错误。同一套词汇的其余部分被另外两个消费方各自私下养大。Schedule 在 `packages/schedule/schedule/src/domain.ts` 里持有严格的 RFC 3339 时刻解析器、本地日历解析器、四位年份边界与规范格式化、本地墙钟解析,以及自己的 IANA 时区规范化实现——它抛 `ScheduleInputError` 而不是回答结果。`dsh-time-context` 在 `packages/context/time-context/src/request-zone.ts` 里持有第二份 IANA 时区剖面,配有自己的正则与自己的 `Intl.DateTimeFormat` 规范化,用来执行更严格的策略:一轮请求只能报出另一个进程会解析回同一标识的时区。因此 IANA 剖面与那一步 `Intl` 规范化合计存在三份,该剖面的任何变更都要逐份改、逐份验;而下一个需要墙钟选择器的消费方没有共享入口可用。 + +## Decision + +`dsh-util-time` 拥有各边界所接收的时间词汇。解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,`src/index.ts` 转出两者,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。 + +本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`out_of_range`、`not_a_calendar_date`、`invalid_numeric_offset`、`malformed`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`isFourDigitYearInstant` 回答某个纪元是否可表示;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。两个严格解析器的原因词汇彼此对称:同一条件在两者中都叫 `out_of_range`,所以年份 0 被 `parseOffsetInstant` 与 `parseLocalCalendar` 同样拒绝;而 `resolveLocalInstant` 接受真实 `Temporal` 日期携带的任何年份,因为它的调用方需要在 UTC 边界推导本地日期。`LocalCalendarTime.year` 记录了这一分工。 + +Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。 + +`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。 + +`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结,不覆盖任何强制转换钩子,且时间段受可精确表示的毫秒范围约束——这正是拆分精确的前提。公开面就是 `fromMilliseconds` 与整数量拆解 `decompose()`,即已迁移消费方所调用的两个入口。 + +受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。 + +可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 + +## Alternatives considered + +**把共享模块保持为 Schedule 私有。** 在 `domain.ts` 旁抽出一个 Schedule 私有的 `time` 模块可以缩小它,而不触碰已发布包、其 lockfile 条目、README 或依赖集合。之所以否决,是因为本次要消除的重复是重复的**剖面**,不是重复的行数:时区正则、`Intl` 规范化步骤与时刻剖面仍会存在两份,下一个接收墙钟选择器的边界仍会复制出第三份。 + +**让本库自带错误类型。** 一个携带原因的 `TimeInputError` 可以让调用方直接重抛而不做映射,缩短每个调用点。之所以否决,是因为 `util/time` 既有契约规定每个边界拥有自己的拒绝词汇,而 Schedule 的消息会通过工具结果的 `{ code, message }` 与客户端的消息卡片到达模型;共享错误类会把一个调用方的措辞带进另一个调用方的界面。 + +**让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。 + +**给毫秒计数加 brand,而不是造类。** 本仓库已有用于不透明标识的 `Branded`,branded 计数也比类更小。之所以否决,是因为 brand 撑不过真正要紧的那步运算:TypeScript 接受 `brandedA + brandedB` 并回答无品牌的 `number`,于是把时间段加到纪元上的调用方会静默丢掉单位,而这正是该类型要防的错误。类会把这种相加变成类型错误,也让整数量拆解只有一个归宿。 + +**用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。 + +**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。 + +## Consequences + +时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它仍然导入本库的两个边界常量用于其 UTC 边界日期运算,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。 + +代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。 + +值类型只发布消费方会调用的部分,因此这个时间段只暴露 `dsh-time-context` 用到的构造入口与拆解,其余不发布。某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地;`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段就是将来会要求第二个单位换算的那些跨度,而类已经携带的范围约束与冻结正是让这次添加保持很小的原因。包 README 把这些尚未迁移的边界记为已知限制。 + +耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得。该测量不是提交进仓库的测试:消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出,于是非有限的读数在产生它的地方失败,而不是渲染出一段文本。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。 + +本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。 diff --git a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml index a5e3035bd0..2f89f011b6 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml @@ -9,11 +9,11 @@ en: b32a7ce6483b9b48 zh: ec0938ad032b99d8 /agent-note-application-consent-for-native-computer-use/decision: - en: 92247ff599c1103f - zh: 26845420a9711c83 + en: 36c560c24c376f12 + zh: 5e4ff72f66574e38 /agent-note-application-consent-for-native-computer-use/alternatives-considered: - en: 4693dcd217c4fedb - zh: 1dc9bf9067314006 + en: 80cd5fd93a42d937 + zh: 2a0e241b1b8c2e1d /agent-note-application-consent-for-native-computer-use/consequences: - en: 0146a69ab2086edc - zh: 4257bc1ab4432c3f + en: 4bda3366af65250a + zh: 7c83561b4db07284 diff --git a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md index 3fb91170e5..389a994714 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md +++ b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md @@ -10,29 +10,38 @@ macOS privileges authorize an execution host, not a user's choice of target appl ## Decision -The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and the provider's existing profile configuration for saved grants. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority. +The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and the provider's existing profile configuration for saved grants. The existing question UI presents the offered labels; the executor rejects custom text or answers that do not select exactly one offered label. Permission-page icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority. -A saved identity combines the canonical executable path with a verified designated signing requirement, represented by a SHA-256 digest. The path prevents authorization from following a copied application, and the signing requirement distinguishes replacements while allowing matching signed updates. Unverifiable and ad-hoc applications receive task access only, tied to a process instance and Agent connection lifetime. Labels do not establish identity. +System-permission buttons call the native helper on the DSH host and remain available to remote browsers. The [usage documentation](../../../../packages/experimental/cua/README.md#use-this-package) explains where to complete authorization; the page has no persistent remote-access notice. + +A saved identity combines the canonical executable path with a verified designated signing requirement, represented by a SHA-256 digest. The path prevents authorization from following a copied application, and the signing requirement distinguishes replacements while allowing matching signed updates. Unverifiable and ad-hoc applications receive session access only, tied to a process instance. Labels do not establish identity. Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses. -Selections of the same application within one task are serialized, so a pending question does not produce competing decisions. Saved-grant additions read the current configuration inside one mutation queue. Refusals prevent another prompt within the task; an explicitly restored saved grant takes precedence at dispatch. +Session decisions are kept in memory by Session ID and application identity. Agent disposal and helper replacement release native handles without discarding these decisions; provider disposal or DSH restart clears them. This scope covers follow-up turns without relying on the model to decide when a user intent has ended. -Removing a saved grant invalidates Agent connections retaining the application's handles and cancels active operations; Agent/provider disposal awaits native connection cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator. +Selections of the same application within one session are serialized, so a pending question does not produce competing decisions. Saved-grant additions and revocations share one mutation queue and read the current grant list inside it. Provider shutdown cancels writes still waiting for HMR admission; admitted writes settle before disposal completes. Refusals prevent another prompt until the user permits asking again; a restored saved grant takes precedence in both dispatch and the permission-page projection. + +Revocation invalidates the Agent connections that retain the application's handles, then awaits native cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator. ## Alternatives considered **Trust model instructions or hide controls.** Retained objects and direct tool calls can still reach native operations. The executor must enforce the decision at dispatch. -**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; task-only access supports unsigned development applications without that promise. +**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; session access supports unsigned development applications without that promise. -**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers. +**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers. The permission UI observes the provider and its owning settings namespace without another authorization store or event channel. + +**Disable permission buttons based on the browser address.** A user may operate the DSH host through screen sharing and complete authorization there. Port forwarding and proxies make the address an unreliable indication of whether the browser and DSH run on the same computer. + +**Use the nearest GUI ancestor as the permission target.** A source-built Desktop app can sit between Terminal and the helper while macOS still attributes Accessibility and Screen Recording to Terminal. Process responsibility supplies the system’s attribution; ancestry alone does not. The optional SPI adds compatibility risk, so lookup failure omits the application instead of guessing. **Keep the native context after revocation.** Its application lookup can return an earlier handle and retain window snapshots. Closing the Agent connection discards its native context through existing transport cleanup; preserving unrelated handles would require a native per-application retirement protocol. ## Consequences -Revoking one application also invalidates other application handles on the same Agent connection. Those applications require fresh selection; saved grants remain reusable, while task grants expire. Other tasks with independent connections remain unaffected unless they also hold access to the revoked application. +Refusing or revoking one application also invalidates other application handles on the same Agent connection. Those applications require fresh selection; session and saved grants remain reusable. Other sessions with independent connections remain unaffected unless they also hold access to the revoked application. +Explicit enablement may start the shared helper for a read-only permission probe. The helper captures the application identified by macOS process responsibility before readiness releases the launcher, retaining its name and icon after reparenting. The optional native query is dynamically resolved; unavailable attribution uses generic system-prompt guidance. The macOS prompt remains authoritative. Enabling does not request macOS authorization or inspect application content. Application decisions and failure results are exercised through policy regressions and the recorded CUA scenario. Desktop binary distribution, signing, and packaged-host permissions remain outside the source-launch workflow. diff --git a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md index 73d4640db7..e7311dfc6d 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md @@ -10,29 +10,38 @@ macOS 权限授权执行宿主,不能表达用户允许操作哪些目标应 ## 决策 -CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。 +CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。现有问答界面展示授权选项;执行器拒绝自由文本或未准确选中一个所给选项的回答。权限页图标和名称仅用于展示,不进入保存的身份信息或模型结果。 -长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得任务授权,许可绑定进程实例和 Agent 连接生命周期。显示名称不用于确定身份。 +系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。 + +长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得会话授权,许可绑定进程实例。显示名称不用于确定身份。 选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。 -同一任务对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增在同一写入队列内读取当前配置。拒绝会阻止任务内的再次询问;用户显式恢复的长期许可在执行检查时优先于旧拒绝记录。 +会话决定按 Session ID 和应用身份保存在内存中。Agent 销毁或 helper 重建会释放原生句柄,但保留授权决定;提供方卸载或 DSH 重启后清除。会话范围可以覆盖用户的后续指令,无须由模型判断一次用户意图何时结束。 -删除长期许可会使保留目标应用句柄的 Agent 连接失效,并取消活动操作;Agent 或提供方销毁时等待对应的原生连接清理。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。 +同一会话对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增和撤销共用写入队列,并在队列内读取当前许可列表。提供方关闭时取消尚在等待 HMR 调度的写入,已开始的写入会在销毁完成前结束。拒绝会阻止再次询问,直到用户允许重询;重新添加的长期许可在执行检查和权限页中均优先于旧拒绝记录。 + +撤销使保留目标应用句柄的 Agent 连接失效,并等待原生清理完成。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。 ## 考虑过的替代方案 **依赖模型指引或隐藏控件。** 保留的对象和直接工具调用仍能到达原生操作。执行器必须在派发时检查用户决定。 -**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;任务授权支持未签名的开发应用,但不承诺跨进程复用。 +**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。 -**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。 +**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。权限界面查询 provider,并监听其配置 namespace,无须另一份授权存储或事件通道。 + +**根据浏览器访问地址禁用权限按钮。** 用户可能通过屏幕共享操作运行 DSH 的电脑,并在该电脑上完成授权。端口转发和代理使访问地址无法可靠判断浏览器与 DSH 是否运行在同一台电脑上。 + +**把最近的 GUI 祖先当作授权对象。** 源码构建的 Desktop 可以位于 Terminal 和 helper 之间,但 macOS 仍将辅助功能和屏幕录制归属于 Terminal。责任进程记录提供系统认定的归属,仅凭父进程链无法确定。可选内部接口有兼容性风险,因此查询失败时省略应用信息,不再猜测。 **撤销后保留原生状态。** 原生应用查找可能返回旧句柄并保留窗口快照。关闭 Agent 连接可以通过现有传输清理释放其原生状态;保留其他应用的句柄则需要新增按应用销毁状态的原生协议。 ## 影响 -撤销一个应用也会使同一 Agent 连接中其他应用的句柄失效。这些应用需要重新选择;长期许可仍可复用,任务许可随之失效。其他任务使用独立连接,除非也持有被撤销应用的许可,否则不受影响。 +拒绝或撤销一个应用也会使同一 Agent 连接中其他应用的句柄失效。这些应用需要重新选择;会话许可和长期许可仍可复用。其他会话使用独立连接,除非也持有被撤销应用的许可,否则不受影响。 +显式启用可以启动共享 helper 进行只读权限检查。helper 在发布就绪状态、让启动器退出之前,记录 macOS 责任进程对应的应用,因此父进程改变后仍能提供应用名称和图标。可选的本机查询通过动态加载解析,无法取得归属时显示通用系统弹窗指引。授权归属仍以 macOS 提示为准。启用不会申请 macOS 授权或检查应用内容。 授权决定和失败结果由策略回归测试及 CUA 录制会话验证。桌面安装包的二进制分发、签名和打包宿主权限不属于源码启动流程。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml new file mode 100644 index 0000000000..dd6e68b389 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-12-session-projection-registration-handles.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-12-session-projection-registration-handles.md +/agent-note-asynchronous-projection-registration-and-retained-readers: + en: 693349255afbaec8 + zh: 13bcbf3f40b93b2b +/agent-note-asynchronous-projection-registration-and-retained-readers/problem: + en: 47ba548b869be190 + zh: c2e5038a56fe741c +/agent-note-asynchronous-projection-registration-and-retained-readers/decision: + en: 9e56d4bcbdc3a291 + zh: bebaaab338a15e40 +/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered: + en: ee5b0cd715ed1fef + zh: 6b1478dcde21916d +/agent-note-asynchronous-projection-registration-and-retained-readers/consequences: + en: 6f0e04ef7924e2c6 + zh: e3730273e323440d diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md new file mode 100644 index 0000000000..90598d8f9f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md @@ -0,0 +1,31 @@ +# Agent Note: Asynchronous projection registration and retained readers + +Status: implemented + +English | [中文](2026-10-12-session-projection-registration-handles.zh.md) + +## Problem + +Host readers coupled to registry key lookup lose access when a plugin unloads, although an Agent may still need projected state while its asynchronous cleanup drains. Repeated batch snapshots materialize unrelated client views. A synchronous registration API also makes every consumer assume that acquisition can never require asynchronous initialization. + +## Decision + +`SessionProjectionRegistry.register` returns a promise for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. + +A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. + +AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. + +## Alternatives considered + +**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order. + +**Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness. + +**Register the same units for each Agent.** The definitions already contain per-Session cells. Additional references and asynchronous Agent initialization add a creation race without providing independent data ownership. + +**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break. + +## Consequences + +Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md new file mode 100644 index 0000000000..1043a77972 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 异步投影注册与保留读取句柄 + +Status: implemented + +[English](2026-10-12-session-projection-registration-handles.md) | 中文 + +## Problem + +依赖注册表键查询的 Host 读取方会在插件卸载时失去访问能力,但 Agent 可能仍需在异步清理排空期间读取投影状态。重复的批量快照会具象化无关 Client 视图。同步注册 API 也使每个消费者假定获取过程永远不需要异步初始化。 + +## Decision + +`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 + +句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 + +AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 + +## Alternatives considered + +**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。 + +**返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。 + +**为每个 Agent 注册相同单元。** 定义已经包含逐 Session 单元。额外引用和异步 Agent 初始化只会引入创建竞态,不提供独立数据所有权。 + +**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。 + +## Consequences + +投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。 diff --git a/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.i18n.yaml new file mode 100644 index 0000000000..22e7100c04 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-14-registry-bundle-updates.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-14-registry-bundle-updates.md +/agent-note-registry-bundle-updates: + en: 9b55699b0d5b3c7b + zh: 729a564af144048d +/agent-note-registry-bundle-updates/problem: + en: 47524f94e08192b5 + zh: 335104b6fda38f03 +/agent-note-registry-bundle-updates/decision: + en: 9201e6cc6cad6b8d + zh: be2332897037c43a +/agent-note-registry-bundle-updates/alternatives-considered: + en: 299b8e211b9c21bf + zh: 72bd106e96b42605 +/agent-note-registry-bundle-updates/consequences: + en: 57c30a10a233c3e0 + zh: 87cd51e2df8696bd +/agent-note-registry-bundle-updates/verification: + en: ab3442415bff92a6 + zh: 89d477c7d14f6098 diff --git a/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md b/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md new file mode 100644 index 0000000000..e17647a7db --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md @@ -0,0 +1,47 @@ +# Agent Note: Registry bundle updates + +Status: implemented + +English | [中文](2026-10-14-registry-bundle-updates.zh.md) + +## Problem + +Third-party bundles installed from a registry had no update path. The plugin page offered **Update** only for Official bundles, whose target is the running DSH version, so a person learned of a newer third-party release outside DSH and replaced it by uninstalling and installing again. With HMR an install over an installed package is refused, and a manual removal followed by a failed installation leaves the plugin uninstalled. + +## Decision + +**Versions are read through `pnpm view` in the profile directory.** `listVersions` runs `pnpm view versions time dist-tags` over the same registry plan as `inspect`, so the `.npmrc` chain, proxy, and authentication that the installation uses also decide what the list shows, and a registry that cannot be reached moves the lookup to the next one. pnpm prints a range lookup as its highest match only, so each listed version takes its own `pnpm view @ name version engines peerDependencies deprecated dsh` on the registry that answered. `versionListLimit` bounds how many newer versions are read and `versionLookupConcurrency` bounds the subprocesses in flight. Compatibility reuses `evaluatePluginCompatibility` with the profile's exemptions, so the list and the installation judge peers the same way. + +**The Host owns the update and its recovery.** `updateBundle` views the target first and refuses a missing version, one that declares no bundle, or one whose `engines.dsh` or DSH peers reject the running version before anything changes. With HMR it then calls `removeBundle` and `installBundle(name@version, { saveExact, registry, enabled })`; when that installation fails, is cancelled, or installs but cannot start, it removes a target that stayed and installs the removed version again from the registry it came from, else over the configured plan. The reinstallation carries no request id, so a cancellation of the update cannot stop it. Without HMR it installs over the running version, which `installBundle` allows there and reports as `restart-required`. `plugin-manager/update-state` announces each step for the dialog's progress list. + +**Eligibility is a listed fact.** `BundleInfo.updatable` marks a third-party profile dependency saved as a registry spec under its own name and not as an `npm:` alias, with a semver version and no plugin management; the page shows the Versions section only for those, and both methods refuse every other bundle. The `plugin_manager` tool, which has no update action, omits the field. + +**Updates only move forward.** The page lists versions newer than the installed one and `updateBundle` refuses any other, so an update never asks a plugin to read data a newer version wrote. + +## Alternatives considered + +**Fetch the abbreviated packument over HTTP.** One request returns every version's `peerDependencies` and `deprecated`, but the Host would have to resolve registry, scope, authentication, and proxy settings from the `.npmrc` chain itself and keep that resolution identical to pnpm's. A list from a registry pnpm would not install from shows versions that cannot be installed. + +**`pnpm outdated`.** It reports current, wanted, and latest only, judges no DSH peers, and with exact saves wanted equals current. + +**Orchestrate removal and installation in the browser.** The Official update did so. A browser that closes or loses its connection between the two calls leaves the package removed with nobody to reinstall it, and the target check before removal would be a second, bypassable copy of the Host's rule. + +**List versions while reading bundles.** `listBundles` is offline; adding registry lookups would make opening the plugin page a network operation with one subprocess per version. + +## Consequences + +- Checking a package costs one `pnpm view` plus one per listed version, each bounded by `inspectTimeoutMs`. +- A target saved by an update, and a reinstallation after a failed one, are exact versions even when the profile had saved a range; with HMR the bundle joins the end of the selection. +- Removal and installation take the profile lock separately, so another operation can run between them; an installation that rejects, for example on a lock timeout, still triggers the reinstallation. +- A version whose dependencies need a new build-script approval fails to install and is reinstalled at the previous version; its scripts are approved through an ordinary installation. +- A reinstallation that also fails leaves the package uninstalled, and the dialog says so. + +## Verification + +| Coverage | Location | +|---|---| +| Listing, registry fallback, refusals, compatibility, prereleases, deprecation, and the version limit | `packages/boot/plugin-manager/tests/manager.spec.ts` | +| Update through removal and installation, reinstallation after a failed, cancelled, or rejected target, a failed reinstallation, refusal before any change, removal failure, and the no-HMR path | `packages/boot/plugin-manager/tests/manager.spec.ts` | +| `pnpm view` answer parsing | `packages/boot/plugin-manager/tests/versions.spec.ts` | +| Version checks, update state, and step progress in the store | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` | +| Versions section, toolbar check, and every update outcome | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` | diff --git a/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.zh.md b/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.zh.md new file mode 100644 index 0000000000..b9a680ed3b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 注册表组合包更新 + +Status: implemented + +[English](2026-10-14-registry-bundle-updates.md) | 中文 + +## 问题 + +从注册表安装的第三方组合包没有更新途径。插件页只为官方组合包提供 **更新**,其目标是当前运行的 DSH 版本,因此用户只能在 DSH 之外得知第三方新版本,并通过卸载再安装来替换。有 HMR 时,安装到已安装的包之上会被拒绝;手动移除后安装失败,插件就处于未安装状态。 + +## 决策 + +**版本通过在 profile 目录中运行 `pnpm view` 读取。** `listVersions` 按与 `inspect` 相同的注册表计划运行 `pnpm view versions time dist-tags`,因此安装所用的 `.npmrc` 链、代理与认证同样决定列表显示什么,连不上的注册表会使查询转向下一个。pnpm 对范围查询只打印最高的匹配版本,因此每个列出的版本在作答的注册表上单独运行一次 `pnpm view @ name version engines peerDependencies deprecated dsh`。`versionListLimit` 限制读取的新版本个数,`versionLookupConcurrency` 限制同时运行的子进程个数。兼容性复用 `evaluatePluginCompatibility` 与 profile 的豁免,因此列表与安装以同样方式判断 peer。 + +**更新及其恢复由 Host 负责。** `updateBundle` 先查询目标版本,在任何改动之前拒绝不存在的版本、未声明组合包的版本,以及 `engines.dsh` 或 DSH peer 拒绝当前运行版本的版本。有 HMR 时,它随后调用 `removeBundle` 与 `installBundle(name@version, { saveExact, registry, enabled })`;该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从被移除版本原来的注册表重新安装它,没有记录时按配置的计划安装。重新安装不带请求 id,因此取消更新无法中止它。没有 HMR 时,它直接安装在运行中的版本之上,`installBundle` 在这种情况下允许这样做并报告 `restart-required`。`plugin-manager/update-state` 通告每一步,供对话框的进度列表使用。 + +**更新资格是列出的事实。** `BundleInfo.updatable` 标记以自身包名、按注册表 spec 保存(不是 `npm:` 别名)、带 semver 版本且不承载插件管理的第三方 profile 依赖;页面只为这些组合包显示版本区块,两个方法都拒绝其他组合包。没有更新操作的 `plugin_manager` 工具省略该字段。 + +**更新只向前。** 页面只列出比已安装版本新的版本,`updateBundle` 拒绝其他版本,因此更新不会让插件读取更新版本写入的数据。 + +## 考虑过的替代方案 + +**通过 HTTP 获取精简 packument。** 一次请求即可返回每个版本的 `peerDependencies` 与 `deprecated`,但 Host 必须自行从 `.npmrc` 链解析注册表、scope、认证与代理设置,并保持与 pnpm 的解析一致。来自 pnpm 不会从中安装的注册表的列表,会显示无法安装的版本。 + +**`pnpm outdated`。** 它只报告 current、wanted 与 latest,不判断 DSH peer,而在确切版本保存下 wanted 等于 current。 + +**在浏览器中编排移除与安装。** 官方组合包的更新就是这样做的。浏览器在两次调用之间关闭或断开连接时,包已被移除且无人重新安装,而移除前的目标检查会成为 Host 规则的第二份、可被绕过的副本。 + +**在读取组合包时列出版本。** `listBundles` 不联网;加入注册表查询会让打开插件页变成网络操作,且每个版本一个子进程。 + +## 后果 + +- 检查一个包需要一次 `pnpm view`,再加上每个列出版本一次,每次都受 `inspectTimeoutMs` 限制。 +- 更新保存的目标版本,以及失败后重新安装的版本,都是确切版本,即使 profile 原先保存的是范围;有 HMR 时,组合包排到选中列表末尾。 +- 移除与安装分别获取 profile 锁,其他操作可能在两者之间运行;安装被拒绝(例如锁等待超时)时,同样会触发重新安装。 +- 依赖需要新的构建脚本批准的版本会安装失败,并重新安装为原版本;其脚本通过普通安装批准。 +- 重新安装也失败时,包处于未安装状态,对话框会如实说明。 + +## 验证 + +| 覆盖 | 位置 | +|---|---| +| 列表、注册表回退、拒绝、兼容性、预发布、弃用与版本上限 | `packages/boot/plugin-manager/tests/manager.spec.ts` | +| 经移除与安装的更新、目标失败、取消或被拒绝后的重新安装、重新安装失败、改动前的拒绝、移除失败与无 HMR 路径 | `packages/boot/plugin-manager/tests/manager.spec.ts` | +| `pnpm view` 答复解析 | `packages/boot/plugin-manager/tests/versions.spec.ts` | +| store 中的版本检查、更新状态与步骤进度 | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` | +| 版本区块、工具栏检查与每种更新结果 | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` | diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml new file mode 100644 index 0000000000..50c86c11f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-15-plugin-detail-documents.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md +/agent-note-plugin-detail-documents: + en: 2cca350bb3d00987 + zh: 02689ad1893d30fe +/agent-note-plugin-detail-documents/problem: + en: 018296714f6bba40 + zh: 5926db9dca265dd7 +/agent-note-plugin-detail-documents/decision: + en: 638ccade77cb14db + zh: c70a83e2ed0e72f7 +/agent-note-plugin-detail-documents/alternatives-considered: + en: 791b7839125f9f99 + zh: f75e3abeb9777494 +/agent-note-plugin-detail-documents/consequences: + en: 0a37ee01fc43ff26 + zh: 156571e1d3181015 diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md new file mode 100644 index 0000000000..5ee84e08b9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md @@ -0,0 +1,50 @@ +# Agent Note: Plugin detail documents + +Status: implemented + +English | [中文](2026-10-15-plugin-detail-documents.zh.md) + +## Problem + +Plugin cards and pages show a one-line description. A user choosing a plugin from a market, or checking what an installed bundle does, needs the longer description that package authors already write as a README, with screenshots, as extension marketplaces of code editors show it. The [market index](2026-10-02-plugin-market-index-format.md) carries list fields only, and an index document cannot carry a Markdown document and its images for every plugin. + +Package READMEs of DSH's own packages are maintainer references rather than user descriptions, so an author needs a way to name a separate user document per language. Packages come from local installations, public npm, other registries, public Git hosts, and private repositories that only the user's Git credentials can read. + +## Decision + +Detail documents are read in a fixed order. For every package the order begins with the Markdown file that `meta.detail` in its `locale/.json` names for the display language, falling back through shorter language prefixes to English, then the package README. A market entry page continues with the README of the repository directory the entry or its package declares, at the default branch. The [document order](../../../../packages/util/plugin-detail/README.md#document-order) of the stateless [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.md) library is the single definition of the package steps; [`plugin-manager`](../../../../packages/boot/plugin-manager/README.md#bundle-detail-documents) and [`plugin-market`](../../../../packages/boot/plugin-market/README.md#plugin-detail-documents) document how they apply it. + +`meta.detail` lives in the `locale/.json` files that [plugin display metadata](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) already uses, without a package-field fallback, and is read only for detail documents, so a malformed value fails the detail read and leaves the plugin's title, description, and icon intact. The package README stands in for the README npm shows, because npm shows the README file of the published version. A source that exists but fails stops the read with a reason instead of falling through, so a missing `meta.detail` file or an oversized repository is reported rather than replaced by a different document. + +Bundle pages of **Plugins** are owned by `plugin-manager` and need no market: `readBundleDetail` reads an installed bundle from its directory, without network access or caching, and an official bundle offered before installation from the npm file CDNs at its offered version. Its image route resolves the bundle and document on every request, so the Host keeps no state. The bundle page shows the document first among its sections. + +The Client chooses which npm file CDNs a read asks first: it asks the existing registry comparison of the Plugins page and passes `npmFileSources`, such as `['npmmirror']` when npmmirror answered fastest. The Host orders its configured CDNs by that list and asks no CDN the configuration omits, so the Host services do not depend on the Client package that owns the comparison. + +Market entry pages are owned by `plugin-market`, which reads the entry version's source: the npm file CDNs for a public npm version, or the pinned Git commit. Repository files come from GitHub's raw host and the GitLab repository files API; an anonymous request to the repository's smart HTTP endpoint tells a missing file from a repository that needs credentials, and such a repository, or one on a host without an API, is read from a cached partial clone made with the user's Git configuration. Clones are depth-1 bare repositories without file contents until a file is read, limited per command and in total, and deleted when an operation on them fails or is cancelled. Any host of an http(s) repository URL may be cloned, over https, http, or ssh when `url..insteadOf` rewrites it: Git's credential helpers answer per host, so a clone sends a host only the credentials the user stored for it. The entry page shows the document first, then versions, then source. Neither page gives the document a heading of its own; the document's own headings name it. + +The Host reads only addresses it derives from installed packages, configured indexes, package manifests, and fixed CDN and API hosts; a client names a bundle or an index entry, never a URL. Images beside a document are served by authenticated Host routes that accept only image types inside the document's root, with a sandbox CSP so an SVG opened directly cannot run on the API origin. Markdown renders through the shared `DetailDocument` primitive, which omits raw HTML instead of showing it as literal text, because a README's HTML is layout for HTML renderers and reads as noise in text form; absolute HTTP(S) images load in the browser without a referrer, and relative links stay text. + +## Alternatives considered + +**Bundle details through the plugin market.** An earlier design read bundle documents in `plugin-market` and contributed them through `plugins.detail.section`, so bundle pages showed nothing with the market bundle off and every bundle read went through the market's cache. Installed packages are local files that need no cache, so `plugin-manager` reads them directly with the shared library. + +**A clone host allowlist and https-only clones.** Credential helpers, keychains, and `.netrc` already scope credentials to their host, so a market naming an unknown host receives no credentials for other hosts; an allowlist only blocked legitimate intranet repositories. + +**Detail fields in the index format.** A generator could publish document URLs, but the Host would then fetch addresses a market chooses, and the index would still not carry images or private documents. + +**npm tarball downloads.** A tarball covers every registry but downloads the whole package to read one file; the CDNs serve single files by exact version. Packages of other registries fall back to the entry's repository. + +**Proxying absolute images through the Host.** The Host would fetch URLs a document author chooses from the user's machine, including intranet and loopback addresses. Browser loads keep that risk out of the Host, at the cost of disclosing the user's IP address to image hosts. + +**Rendering raw HTML, and clickable relative links.** Many READMEs use HTML for layout, but rendering it requires sanitizing untrusted markup, so it is omitted; relative links would need a navigation target for every source. Both are deferred. + +**Full or blob-limited clones.** A full clone downloads every file of large repositories, and a blob size limit still downloads every small file. A depth-1 partial clone downloads commits and trees and fetches only the files a document reads. + +## Consequences + +- Authors write a user document per language under `meta.detail`; other packages show their README. +- An installed bundle without `meta.detail` or a README shows no document; its declared repository is read only on market entry pages. +- A repository README is read at the default branch and can differ from the listed version. +- The first missing file of a public GitHub or GitLab repository costs one anonymous smart HTTP request, cached for `cacheTtlMs`. +- Private repositories work when the user's Git credentials reach the host. +- Unit tests cover the document order, CDN fallback, API and clone reads, size limits, eviction, and image paths with real local repositories; real-composition tests read an installed bundle's localized document through `plugin-manager` and a market entry's document through `plugin-market`, and serve their images through the composed profiles. diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md new file mode 100644 index 0000000000..de6587fe49 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 插件详细介绍文档 + +Status: implemented + +[English](2026-10-15-plugin-detail-documents.md) | 中文 + +## 问题 + +插件卡片和页面只显示一行描述。用户从市场中选择插件,或查看某个已安装组合包(bundle)的用途时,需要包作者已经以 README 形式写好的、带截图的较长描述,与代码编辑器的扩展市场显示的内容相同。[市场索引](2026-10-02-plugin-market-index-format.zh.md)只携带列表字段,一份索引文档也无法为每个插件携带一份 Markdown 文档及其图片。 + +DSH 自身各包的 README 是面向维护者的参考,而不是面向用户的介绍,因此作者需要一种方式,为每种语言指定一份单独的用户文档。包可能来自本地安装、公共 npm、其他注册表、公共 Git 主机,以及只有用户的 Git 凭据才能读取的私有仓库。 + +## 决策 + +详细介绍文档按固定顺序读取。对每个包,顺序首先是其 `locale/.json` 中 `meta.detail` 为显示语言指定的 Markdown 文件,该语言没有时依次回退到更短的语言前缀,最后回退到英文;然后是包的 README。市场条目页面在此之后继续读取条目或其包所声明仓库目录中的 README,读取默认分支上的文件。[文档顺序](../../../../packages/util/plugin-detail/README.zh.md#document-order)属于无状态库 [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.zh.md),是包内各步骤的唯一定义;[`plugin-manager`](../../../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 和 [`plugin-market`](../../../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents) 说明它们如何应用该顺序。 + +`meta.detail` 位于[插件展示元信息](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)已经使用的 `locale/.json` 文件中,没有包字段回退,并且只在读取详细介绍文档时读取,因此格式错误的值只让详细介绍的读取失败,插件的标题、描述和图标不受影响。包的 README 代替 npm 显示的 README,因为 npm 显示的就是已发布版本中的 README 文件。存在但读取失败的来源会带着原因终止读取,而不是继续尝试下一个来源,因此缺失的 `meta.detail` 文件或过大的仓库会被报告,而不是被另一份文档替代。 + +**插件**页的组合包页面由 `plugin-manager` 负责,不需要市场:`readBundleDetail` 从已安装组合包的目录读取文档,不访问网络,也不缓存;当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取。其图片路由在每次请求时解析组合包和文档,因此 Host 不保留状态。组合包页面在其各部分中首先显示该文档。 + +由 Client 选择一次读取先请求哪些 npm 文件 CDN:它询问插件页现有的注册表比较,并传入 `npmFileSources`,例如 npmmirror 响应最快时传入 `['npmmirror']`。Host 按该列表排列其已配置的 CDN,且不请求配置未列出的 CDN,因此 Host 服务不依赖负责该比较的 Client 包。 + +市场条目页面由 `plugin-market` 负责,它读取条目版本的来源:公共 npm 版本从 npm 文件 CDN 读取,Git 版本从其固定的提交读取。仓库文件通过 GitHub 的原始文件主机和 GitLab 仓库文件 API 读取;一次不带任何凭据、发往该仓库 smart HTTP 端点的请求区分文件不存在与仓库需要凭据这两种情况,需要凭据的仓库以及所在主机没有 API 的仓库,从使用用户 Git 配置创建并缓存的部分克隆中读取。克隆是深度为 1 的裸仓库,在读取文件前不包含文件内容,按单条命令和总量限制大小,对其执行的操作失败或被取消时即被删除。http(s) 仓库 URL 的任何主机都可以克隆,可通过 https、http,或在 `url..insteadOf` 改写该 URL 时通过 ssh:Git 的凭据助手按主机给出凭据,因此克隆只向一个主机发送用户为该主机保存的凭据。条目页面先显示文档,然后是版本,然后是来源信息。两个页面都不为文档提供单独的标题;文档由其自身的标题命名。 + +Host 只读取它从已安装的包、已配置的索引、包 manifest 以及固定的 CDN 和 API 主机推导出的地址;客户端只指定一个组合包或一个索引条目,从不指定 URL。文档旁的图片由需要认证的 Host 路由提供,这些路由只接受文档根目录内的图片类型,并带有 sandbox CSP,使直接打开的 SVG 无法在 API origin 上运行。Markdown 通过共享的 `DetailDocument` 原语渲染,该原语省略原始 HTML,而不是将其显示为原样文本,因为 README 中的 HTML 是供 HTML 渲染器使用的排版,以文本形式显示时只会干扰阅读;绝对 HTTP(S) 地址的图片由浏览器不带 referrer 加载,相对链接保持为文本。 + +## 考虑过的替代方案 + +**通过插件市场提供组合包详细介绍。** 早先的设计在 `plugin-market` 中读取组合包文档,并通过 `plugins.detail.section` 提供它们,因此关闭市场组合包时组合包页面不显示任何内容,且每次组合包读取都经过市场的缓存。已安装的包是不需要缓存的本地文件,因此由 `plugin-manager` 使用共享库直接读取。 + +**克隆主机允许列表和只允许 https 克隆。** 凭据助手、钥匙串和 `.netrc` 已经按主机限定凭据,因此指定未知主机的市场不会得到其他主机的凭据;允许列表只会阻止合法的内网仓库。 + +**在索引格式中加入详细介绍字段。** 生成器可以发布文档 URL,但这样 Host 就会获取市场选择的地址,而且索引仍然无法携带图片或私有文档。 + +**下载 npm tarball。** tarball 适用于所有注册表,但为了读取一个文件要下载整个包;CDN 按精确版本提供单个文件。其他注册表的包改为从条目的仓库读取。 + +**通过 Host 代理绝对地址的图片。** Host 会从用户的机器获取文档作者选择的 URL,包括内网和回环地址。由浏览器加载可以让 Host 避开这一风险,代价是向图片主机暴露用户的 IP 地址。 + +**渲染原始 HTML,以及可点击的相对链接。** 许多 README 使用 HTML 排版,但渲染它需要清理不可信的标记,因此省略它;相对链接需要为每种来源提供跳转目标。两者都已延后。 + +**完整克隆或按 blob 大小限制的克隆。** 完整克隆会下载大型仓库的每个文件,按 blob 大小限制仍会下载每个小文件。深度为 1 的部分克隆只下载提交和树对象,并只获取文档读取的文件。 + +## 后果 + +- 作者在 `meta.detail` 下为每种语言编写一份用户文档;其他包显示其 README。 +- 没有 `meta.detail` 也没有 README 的已安装组合包不显示文档;其声明的仓库只在市场条目页面上读取。 +- 仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致。 +- 公开 GitHub 或 GitLab 仓库中第一次遇到缺失的文件时,需要发送一次匿名的 smart HTTP 请求,其结果缓存 `cacheTtlMs`。 +- 用户的 Git 凭据能访问该主机时,即可读取私有仓库。 +- 单元测试使用真实的本地仓库,覆盖文档顺序、CDN 回退、API 与克隆读取、大小限制、淘汰和图片路径;真实组合测试通过 `plugin-manager` 读取已安装组合包的本地化文档,通过 `plugin-market` 读取市场条目的文档,并通过组合后的 profile 提供其图片。 diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml index afb6f6cd25..fe9f28b0f2 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml @@ -12,8 +12,11 @@ en: a6dca710f2f43b03 zh: 49b507d106c5ec81 /agent-note-performance-baselines-for-tool-heavy-backend-continuation/calibration-evidence: - en: b1bdf65b8b017b68 - zh: 7e8ed6c18bc6aad1 + en: 16498006f02baebb + zh: 7b64cb0629ee1753 +/agent-note-performance-baselines-for-tool-heavy-backend-continuation/calibration-evidence/sdk-minimal-checkpoint-calibration: + en: f4b52851e35cec56 + zh: b419546dda8abf21 /agent-note-performance-baselines-for-tool-heavy-backend-continuation/alternatives-considered: en: 3a61dff7c926eb22 zh: 8f2633b16be78233 diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md index dd545253ff..e1de8eec76 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md @@ -39,11 +39,11 @@ The implementation reference is the PR #3537 merge on Apple M4 Pro, macOS arm64, | Tool continuation | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 | | Child catalog | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 | -Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. SDK time expectations use the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix. +Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. The historical SDK time expectation used the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix. A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings separately report the first and second parent-catalog observations. -The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its 1,700 ms reference expectation yields a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider. +The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its historical 1,700 ms reference expectation yielded a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider. The first Linux x64 CI measurement ran on `VM-7-113-ubuntu-ci-10` with Node 24.18.1 (run 34017868081, attempt 1, job 101444810498). The SDK median was 2,753.441 ms against its 4,250 ms budget, and tool-continuation retained-heap median was 22.274 MiB against 28.75 MiB. Request-history and tool-continuation time budgets failed: 785.498 ms against 550 ms and 1,077.285 ms against 850 ms, respectively. The unchanged Session-reopen open phase also failed at 31.6 ms against 30 ms. Attempt 2, job 101447076381 (run 34017868081) passed every benchmark on the same commit and unchanged budgets, but used `VM-7-113-ubuntu-ci-29` with Node 24.19.0. The gate runner suppressed successful child output, so that attempt supplies a passing verdict rather than raw medians. The changed runner and Node version prevent attributing the difference solely to contention or claiming stable repeated CI calibration; neither the budgets nor the shared scale are changed on this evidence. @@ -53,6 +53,23 @@ Tool continuation also uses a 900 ms expected CI duration with 1.25× headroom ( Baseline request history uses a 600 ms expected CI duration with 1.25× headroom (750 ms). With unchanged implementation, standard two-CPU hosted run 34035306987, job 101492163630 reports totals of 618.598, 618.606, 582.035, 582.304, and 581.832 ms: median 582.304 ms exceeds the historical 550 ms budget. The 220 ms M4 expectation remains historical evidence. The same measured-verdict assertion accepts the recorded unrounded median under 750 ms, rejects it under 550 ms, and rejects a synthetic 900 ms regression. This calibrates the unoptimized baseline only; workload, timing, product code, and memory budgets remain unchanged. +### SDK-minimal checkpoint calibration + +The SDK-minimal profile uses awaited checkpoints before steps, model requests, and top-level tools. This adds repeated durable writes to the timed path; the buffered-write reference above is historical. The accepted hosted expectation is 5,500 ms. Applying the existing 1.25 variance headroom gives 6,875 ms, rounded up to a whole second for a 7,000 ms budget. The 2× local-to-CI scale is not applied to a duration measured directly on CI. Other case budgets, shared factors, sample counts, and worker deadlines remain unchanged. + +The following four complete attempts of CI run 39198687926 in PR #6369 use the same checkpoint-enabled code, 100 turns, 200 requests, 800 real file reads, zstd persistence, and the original 4,250 ms assertion. Each sample starts a fresh process. All runners expose two CPUs on Linux x64 with Node 24.21.0. Times are milliseconds. + +| Attempt / job | CPU | Five raw total times | Median | +|---|---|---|---:| +| 1 / 117886905086 | AMD EPYC 7763 | 4009.703, 3974.780, 4073.098, 3998.816, 4065.456 | 4009.703 | +| 2 / 117898305589 | AMD EPYC 9V74 | 4182.691, 5946.327, 5581.127, 5432.567, 4362.942 | 5432.567 | +| 3 / 117906071823 | Intel Xeon 6973P-C | 2841.519, 2897.759, 2985.475, 2920.282, 2863.954 | 2897.759 | +| 4 / 117922218658 | AMD EPYC 7763 | 4388.420, 4224.595, 4312.915, 4288.871, 4336.641 | 4312.915 | + +The upper observed run median is 5,432.567 ms; the slowest single sample is 5,946.327 ms. The 5,500 ms expectation rounds above the upper median, and the final 7,000 ms ceiling leaves approximately 29% above it. Runner variation is retained in the evidence, not filtered into a fastest-run result. The later canceled attempt contributes no completed sample group. + +This calibration accepts the measured cost of the stronger durability policy; it is not a performance improvement. Local on/off diagnosis attributes most additional persistence time to frequent file synchronization. The browser input-overlap failures in attempts 1 and 4 are separate failures and receive no exemption from this budget. Deterministic controls use the measured-verdict assertion: the recorded upper-median sample group fails the old 4,250 ms ceiling and passes 7,000 ms, while a synthetic 7,800 ms median remains rejected. + ## Alternatives considered **Repeat existing migration and first-open variants.** Rejected: those twelve cases already distinguish read-only preparation from writable publication. These cases use the current generation and begin or continue actual model work, or enumerate a corpus rather than open one Session. diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md index 9817b72a44..cc96d63e95 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md @@ -39,11 +39,11 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor | 工具续聊 | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 | | 子会话目录 | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 | -续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。SDK 时间期望值使用现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求历史基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。 +续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。历史 SDK 时间期望值使用了现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求历史基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。 独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别报告第一次和第二次 parent 目录观察。 -已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。1,700 ms 参考期望对应 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。 +已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。历史 1,700 ms 参考期望对应当时的 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。 首次 Linux x64 CI 测量使用 `VM-7-113-ubuntu-ci-10` 和 Node 24.18.1(run 34017868081,attempt 1,job 101444810498)。SDK 中位数为 2,753.441 ms,预算为 4,250 ms;工具续聊保留堆中位数为 22.274 MiB,预算为 28.75 MiB。请求历史与工具续聊时间预算失败:分别为 785.498 ms 对 550 ms、1,077.285 ms 对 850 ms。未修改的 Session 重开 open 阶段也以 31.6 ms 对 30 ms 失败。Attempt 2,job 101447076381 (run 34017868081) 在同一提交和未修改预算下通过全部基准,但使用 `VM-7-113-ubuntu-ci-29` 和 Node 24.19.0。门禁运行器隐藏成功子进程的输出,因此该次运行只提供通过结论,不提供原始中位数。Runner 与 Node 版本同时变化,不能把差异仅归因于资源争用,也不能宣称已获得稳定的重复 CI 校准;这些证据不改变预算或共享比例。 @@ -53,6 +53,23 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor 基线请求历史使用 600 ms CI 期望时间与 1.25× 余量(750 ms)。未修改的实现在标准双 CPU 托管 run 34035306987,job 101492163630 中报告总时间为 618.598、618.606、582.035、582.304 和 581.832 ms:中位数 582.304 ms 超出历史 550 ms 预算。220 ms M4 期望保留为历史证据。与实测判定相同的断言在 750 ms 下接受未经舍入的录制中位数,在 550 ms 下拒绝它,并拒绝合成的 900 ms 回退。此校准仅针对未优化基线;负载、计时、产品代码和内存预算均不改变。 +### SDK-minimal 检查点校准 + +SDK-minimal profile 在 step、模型请求和顶层工具执行前等待检查点。这使重复持久化写入进入计时路径;上方基于缓冲写入的参考值仅作历史记录。已接受的托管期望值为 5,500 ms,乘现有 1.25 波动余量得到 6,875 ms,再向上取整到整秒,预算为 7,000 ms。直接在 CI 测得的时间不再乘本地到 CI 的 2× 比例。其他用例预算、共享系数、样本数量和 worker 截止时间均保持不变。 + +以下是 PR #6369 中 CI run 39198687926 的四次完整尝试,使用相同的 checkpoint 启用代码、100 轮、200 次请求、800 次真实文件读取、zstd 持久化和原 4,250 ms 断言。每个样本启动新进程。所有 runner 均为 Linux x64、两个可用 CPU、Node 24.21.0。时间单位为毫秒。 + +| 尝试 / job | CPU | 五个原始总耗时 | 中位数 | +|---|---|---|---:| +| 1 / 117886905086 | AMD EPYC 7763 | 4009.703, 3974.780, 4073.098, 3998.816, 4065.456 | 4009.703 | +| 2 / 117898305589 | AMD EPYC 9V74 | 4182.691, 5946.327, 5581.127, 5432.567, 4362.942 | 5432.567 | +| 3 / 117906071823 | Intel Xeon 6973P-C | 2841.519, 2897.759, 2985.475, 2920.282, 2863.954 | 2897.759 | +| 4 / 117922218658 | AMD EPYC 7763 | 4388.420, 4224.595, 4312.915, 4288.871, 4336.641 | 4312.915 | + +实测轮次中位数上界为 5,432.567 ms,最慢单次样本为 5,946.327 ms。5,500 ms 期望值向上取整覆盖该中位数,最终 7,000 ms 上限比它高约 29%。Runner 差异保留在证据中,不筛选为最快运行结果。后续取消的尝试不贡献完整样本组。 + +此校准接受更强持久化策略的实测成本,不表示性能已改善。本地开关对照将额外持久化时间的大部分归因于频繁的文件同步。第 1、4 次尝试中的浏览器输入重叠失败属于独立问题,不因本预算而豁免。确定性对照与实测判定使用同一断言:记录的最高中位数样本组被旧 4,250 ms 上限拒绝,在 7,000 ms 下通过;合成的 7,800 ms 中位数仍被拒绝。 + ## 考虑过的替代方案 **重复现有迁移和首次打开变体。** 拒绝:现有十二个用例已经区分只读准备与可写发布。这些用例使用当前代际并开始或继续实际模型工作,或者列举语料集合而不是打开单个 Session。 diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml index 38607ee2df..4a98c2f555 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml @@ -18,8 +18,8 @@ en: 1310a89c351e5bfe zh: e6386ae10b893c2d /agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints: - en: b4d81575ac574f6f - zh: f4f291ab7ce83094 + en: 11937dfa4a349a19 + zh: b91a136890fe92e8 /agent-note-performance-baselines-for-large-session-corpora/calibration-evidence: en: f934c95748dcbfe0 zh: df6a68286c21510f diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md index 233491ebe0..2efe66ead2 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md @@ -37,6 +37,8 @@ The projection cache is part of the list endpoint: each cold row carries cached Workers are compiled plain-Node processes, each started fresh against a seeded root. They compose the production Session store, projection registry, JSONL persistence, SQLite Session query with `openAt: first-search` on an in-memory index, JSON storage and projection cache, the Web projection set, AgentLoop, and the real `SessionController`. Transport lookups, upload registration, default model selection, and the Workspace registry are inert providers, because no measured path depends on them. +The shared corpus and projection-list controller fixture awaits controller activation and supplies the fixed attachment image policy. Its snapshots therefore include the production `imageLimits` unit; attachment I/O remains outside these workloads. Event corpora, measurement endpoints, and budgets are unchanged. + | Case | Corpus | Samples | Endpoint | |---|---|---:|---| | List | 3,000 | 3 | Host boot; first `session.list`; repeated `session.list` in the same process | diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md index 4ed0283df7..f964d640b8 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md @@ -37,6 +37,8 @@ Status: implemented worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新启动。它们组合生产 Session store、投影注册表、JSONL 持久化、使用 `openAt: first-search` 和内存索引的 SQLite Session query、JSON 存储和投影缓存、Web 投影集合、AgentLoop 以及真实的 `SessionController`。传输查找、上传注册、默认模型选择和 Workspace 注册表使用惰性提供者,因为没有被测路径依赖它们。 +语料与 projection-list 共用的控制器夹具等待控制器激活,并提供固定附件图片策略。因此快照包含生产 `imageLimits` 单元;附件 I/O 仍不在这些负载中。事件语料、测量端点和预算保持不变。 + | 用例 | 语料 | 样本数 | 终点 | |---|---|---:|---| | 列表 | 3,000 | 3 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` | diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index c73869d5ff..4d929aec54 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: bfa4b063696492a5 - zh: 3cee9b6087dbee91 + en: fcc52be9892b2bfc + zh: ac259aba407efcbc /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 7600c1671b5dc73c - zh: b38e3ae76a5a32d1 + en: 168bda1a4e9e0325 + zh: af9cf7b804db84b7 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index abf01cca55..e4373eb7dc 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations; a 5,000-message sample evaluates the batch once. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | @@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi ## Alternatives considered +- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit. - **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. - **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index f5b57e883e..f081970b1c 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,7 +43,7 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值,5,000 条消息的样本则整批求值一次。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | @@ -54,6 +54,7 @@ Status: implemented ## 考虑过的替代方案 +- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。 - **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 - **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 59a262d59e..e0639785f1 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -15,8 +15,8 @@ en: e6c93d89a25f0d03 zh: 65e1f5842bc74008 /agent-note-session-projections-and-command-lifecycle-logging/proposal/host-projection-registry-dsh-session-projection-new-package: - en: a0f86a588c9dff31 - zh: 421803bc8b0dddb5 + en: bb52ae77e0302d2d + zh: 75e6a76ce7d02342 /agent-note-session-projections-and-command-lifecycle-logging/proposal/shipped-consumer-the-subagent-identity-unit: en: 10093535660c3cae zh: a7f4ff9e71ee6751 @@ -39,8 +39,8 @@ en: 64ad8d08a8ab44e0 zh: fb0a0f7369d0bd23 /agent-note-session-projections-and-command-lifecycle-logging/alternatives-considered: - en: e945e84e8360e0c4 - zh: becdeb100a4c5b6b + en: 4776bd01e7ec14f0 + zh: 9b1c2db96e1120f6 /agent-note-session-projections-and-command-lifecycle-logging/acceptance-criteria: en: 9dfcca30fffda225 zh: 0beddb81d32e0ca7 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 4e2e02482e..f907f77959 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -24,15 +24,14 @@ A state-carrying log event MUST carry the complete post-change state, never a ba ### Host projection registry (`dsh-session-projection`, new package) -A light Service Definition package: merge-extensible host-state and client-view type maps, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other. +A light Service Definition package: local host-state definitions, a merge-extensible client-view type map, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other. What a domain registers is a **state-driven computation unit** — a pure fold plus declarations and an optional client view — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the computation. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract. ```ts ignore-check -export interface SessionProjectionStateMap {} // host fold states -export interface SessionProjectionMap {} // client-visible whole values +export interface SessionProjectionMap {} // client-visible whole values -export interface ProjectionDefinition { +export interface ProjectionDefinition { key: K stateSchema: ZodType persist?: boolean // host-only units opt in; client-visible units always persist @@ -54,7 +53,7 @@ declare module 'cordis' { } ``` -- `SessionProjectionStateMap` types host fold states; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). +- `ProjectionDefinition` carries each unit's host state type; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). - **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. - **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs). @@ -161,7 +160,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a **Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package. -**A second client DTO table** — rejected: `SessionProjectionMap` remains the single client vocabulary shared by wire and UI. `SessionProjectionStateMap` is not another client view table; it types host fold state so internal state may differ from the value sent to clients. +**A second client DTO table** — rejected: `SessionProjectionMap` remains the single client vocabulary shared by wire and UI. Each unit types its host fold state locally, independently of the value sent to clients. **Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots). diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index f226acfc83..e3f23ec975 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -24,15 +24,14 @@ Status: proposed ### host 侧投影注册表(`dsh-session-projection`,新包) -一个轻量的 Service Definition 包:merge-extensible 的 host 状态与客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。 +一个轻量的 Service Definition 包:局部 host 状态定义、merge-extensible 的客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。 领域注册的是一个**状态驱动计算单元(state-driven computation unit)**——纯折叠、若干声明及可选客户端视图——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责计算。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本约定中没有任何特殊地位。 ```ts ignore-check -export interface SessionProjectionStateMap {} // host fold states -export interface SessionProjectionMap {} // client-visible whole values +export interface SessionProjectionMap {} // client-visible whole values -export interface ProjectionDefinition { +export interface ProjectionDefinition { key: K stateSchema: ZodType persist?: boolean // host-only units opt in; client-visible units always persist @@ -54,7 +53,7 @@ declare module 'cordis' { } ``` -- `SessionProjectionStateMap` 描述 host 折叠状态;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)。 +- `ProjectionDefinition` 携带各单元的宿主状态类型;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)。 - **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 - **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 @@ -161,7 +160,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 **把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACP(Agent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。 -**第二张客户端 DTO 类型表**——不予采纳:`SessionProjectionMap` 仍是协议与 UI 共享的唯一客户端词汇。`SessionProjectionStateMap` 不是另一张客户端视图表;它描述 host 折叠状态,使内部状态可以不同于发往客户端的值。 +**第二张客户端 DTO 类型表**——不予采纳:`SessionProjectionMap` 仍是协议与 UI 共享的唯一客户端词汇。各单元在内部声明宿主折叠状态类型,与发往客户端的值相互独立。 **用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot)。 diff --git a/AGENTS.md b/AGENTS.md index 3212233b96..5ee465f84d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - Packages use `@deepseek-ai/dsh-`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. -- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. +- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer, directly or through its documented registration handle. - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read unless the envelope carries `ignorable: true`. Bump `SESSION_FORMAT_VERSION` only when existing discriminators cannot prevent unsafe interpretation; same-version changes require safe reader behavior ([review](.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md)). - **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)). diff --git a/apps/cli/package.json b/apps/cli/package.json index 6f4f927b48..d7c3e78f72 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -105,6 +105,7 @@ "@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", + "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" }, "devDependencies": { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 5174026bc4..cd8b5aad9c 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1276,6 +1276,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ['session-checkpoint-policy', '@deepseek-ai/dsh-session-checkpoint-policy'], ]) expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') expect(stdout).not.toContain('@deepseek-ai/dsh-base') diff --git a/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts new file mode 100644 index 0000000000..8f486aa25d --- /dev/null +++ b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts @@ -0,0 +1,72 @@ +/** The shipped minimal profile must stop model dispatch when a durable checkpoint fails. */ +import { createServer } from 'node:http' +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import { describe, expect, it, onTestFinished } from 'vitest' + +const dshBin = fileURLToPath(new URL('../../../lib/bin.js', import.meta.url)) +const fixture = fileURLToPath(new URL('./fixtures/checkpoint-failure.mjs', import.meta.url)) + +describe.skipIf(!existsSync(dshBin))('sdk-minimal durability checkpoints', () => { + it.each([false, true])('checkpoint storage failure with policy disabled=%s', async (disabled) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-checkpoint-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + let modelRequests = 0 + const server = createServer((request, response) => { + request.resume() + request.on('end', () => { + modelRequests += 1 + const events = [ + { type: 'message_start', message: { id: 'checkpoint-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: 'OK' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ] + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) + }) + }) + onTestFinished(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error) + else resolve() + }) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model server has no TCP address') + const patch = join(root, 'checkpoint.patch.yml') + await writeFile(patch, JSON.stringify([ + { id: 'session-log-deepseek', config: { enabled: false } }, + { insert: [{ id: 'checkpoint-failure', name: fixture }] }, + ...disabled ? [{ id: 'session-checkpoint-policy', disabled: true }] : [], + ])) + const harness = new DeepSeekHarness({ + dshBin, profile: 'sdk-minimal', dshHome: join(root, 'home'), + cwd: root, processCwd: root, patches: [patch], + env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-checkpoint-test', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, + }) + onTestFinished(async () => { await harness.close() }) + const result = await harness.run('Reply OK without calling tools.') + expect(modelRequests).toBe(disabled ? 1 : 0) + const terminal = result.events.findLast(event => event.type === 'turn/end') + expect(terminal).toMatchObject({ data: { reason: { kind: disabled ? 'completed' : 'error' } } }) + if (disabled) { + expect(result.finalResponse).toBe('OK') + } else { + expect(terminal).toMatchObject({ + data: { reason: { kind: 'error', error: { message: 'sdk checkpoint persistence unavailable' } } }, + }) + } + }) +}) diff --git a/apps/cli/tests/profiles/sdk/fixtures/checkpoint-failure.mjs b/apps/cli/tests/profiles/sdk/fixtures/checkpoint-failure.mjs new file mode 100644 index 0000000000..7a37a58215 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/checkpoint-failure.mjs @@ -0,0 +1,13 @@ +/** Reject durable checkpoints without changing background JSONL writes. */ +export const name = 'sdk-checkpoint-failure' +export const inject = ['sessionPersistence'] + +/** + * Install a storage-failure signal at the public session flush event. + * @param ctx - Plugin context owning the flush listener. + */ +export function apply(ctx) { + ctx.on('session/flush', () => { + throw new Error('sdk checkpoint persistence unavailable') + }) +} diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index b8e742ac0b..9bc37e78b2 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,3 +1,5 @@ +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts' import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' @@ -327,13 +329,14 @@ describe('the shipped Web composition', () => { sessionId: SessionId('preset-model-selection-enabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) try { expect(toolNames(ctx, disabled.agent)).toContain('list_subagent_models') expect(toolParameterNames(ctx, disabled.agent, 'subagent')).toEqual(expect.arrayContaining([ 'model', 'provider', 'reasoning_effort', ])) - expect(ctx.sessionProjections.stateOf(disabled.agent.session, 'subagentModelSelectionPolicy')).toBeNull() - expect(ctx.sessionProjections.stateOf(enabled.agent.session, 'subagentModelSelectionPolicy')).toEqual([ + expect(policy.stateOf(disabled.agent.session)).toBeNull() + expect(policy.stateOf(enabled.agent.session)).toEqual([ { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, ]) expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models') @@ -344,6 +347,7 @@ describe('the shipped Web composition', () => { expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'prompt']) } } finally { + await policy.dispose() await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) await enabled.dispose() await disabled.dispose() @@ -879,7 +883,7 @@ describe('a switch survives the session', () => { // The header keeps the creation fact; the log carries what it runs. expect(handle.agent.session.header.agentPreset).toBe('standard') - expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(handle.agent.session).agentPreset?.val).toBe('minimal') } finally { await handle.dispose() } @@ -894,7 +898,8 @@ describe('a forked session', () => { meta: { agentPreset: 'minimal' }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) - const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined + const projection = await ctx.sessionProjections.register(agentPresetProjectionDefinition) + const inherited = projection.stateOf(parent.agent.session) ?? undefined const child = await ctx.agents.create({ sessionId: SessionId('preset-fork-child'), seed: [], diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index a46786a046..bda88b0fc6 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -42,8 +42,8 @@ en: 98cf848762482d48 zh: 74f9100a39ec8c2f /deepseek-harness-desktop/develop: - en: fe6d23e6a3d3bc66 - zh: d60c8d3a55747916 + en: faf7676ba18bbf2f + zh: f1d7b75ad348c548 /deepseek-harness-desktop/develop/startup-onboarding: en: a51346b8d880ddba zh: 7913214c1c66063c @@ -54,11 +54,11 @@ en: ac31ebdd9de54f34 zh: 921e534d5c4b3a16 /deepseek-harness-desktop/windows-installer-interface: - en: 8cb4b7babbd8714d - zh: b4aebc1d2d152e01 + en: 9b90a2651a6d2b52 + zh: 86d4d9c4358aad80 /deepseek-harness-desktop/updates: - en: b83a37681e23a3cc - zh: e11ab8381fa4362d + en: 87db37529b51da62 + zh: 4fc0d752e437e7d6 /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 14bfd38f14..45c94d62c7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -202,6 +202,8 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` +Desktop package preparation filters optional native packages by the installer target’s OS and architecture. Windows package sets omit the macOS Computer Use tarball, including when their inputs were packed on macOS. + The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile as part of the repository Client typecheck. Their Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules. @@ -253,9 +255,9 @@ The Windows installer uses native NSIS pages with light and dark palettes, syste The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation. -Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. +Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The progress page keeps one opaque pixel buffer for its lifetime, draws static artwork once, and copies repainted pixels with GDI. The preparation hook leaves production dependency collection to electron-builder on every platform. -Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. +Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The renderer receives caption colors from the NSIS theme; pixel checks print into an off-screen bitmap and also run with the progress child hidden. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only. @@ -267,6 +269,8 @@ Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --com On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once when startup opens the workspace directly, without enabling always-on-top. Startup into the welcome page discards that request so a later login keeps its normal activation behavior. Ordinary launches and other platforms do not use this foregrounding step. +On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window. + Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 64cc5f7b22..0504c7004c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -202,6 +202,8 @@ pnpm run dev:desktop pnpm run start:desktop ``` +桌面包准备过程按安装包目标的操作系统和架构筛选可选原生包。Windows 的包集合不包含 macOS Computer Use tarball,即使输入包是在 macOS 上打包的。 + Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 [原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)直接纳入仓库 Client 类型检查。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。 @@ -253,9 +255,9 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影 主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。 -Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 +Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。进度页在其生命周期内保留一个不透明像素缓冲,只绘制一次静态图像,并通过 GDI 复制重绘的像素。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 -在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 +在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。绘制器接收 NSIS 主题传入的标题栏颜色;像素检查绘制到离屏位图,并在隐藏进度子窗口时验证。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。 @@ -267,6 +269,8 @@ Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APP Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。 +macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。 + 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 0682e6e9ad..d6a1a63bfa 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib' /** Electron-builder fields asserted by the Desktop release tests. */ export interface DesktopElectronBuilderConfig { readonly appId: string + readonly copyright: string readonly artifactName: string readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }] readonly directories: { @@ -43,6 +44,7 @@ export interface DesktopElectronBuilderConfig { } } readonly nsis: { + readonly uninstallDisplayName: '${productName}' readonly include: string readonly oneClick: false readonly perMachine: false diff --git a/apps/desktop/installer/lifecycle.nsh b/apps/desktop/installer/lifecycle.nsh index 525fde4080..521bfe20fe 100644 --- a/apps/desktop/installer/lifecycle.nsh +++ b/apps/desktop/installer/lifecycle.nsh @@ -62,7 +62,7 @@ Function InstallerProgressShow StrCpy $0 1 StrCpy $1 "$PLUGINSDIR\brand-dark-2x.bmp" ${EndIf} - System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c' + System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, i $InstallerControlHover, i $InstallerControlPressed, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c' Pop $InstallerProgressWindow ${If} $InstallerProgressWindow == 0 MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)" diff --git a/apps/desktop/installer/pages.nsh b/apps/desktop/installer/pages.nsh index 13ce171cc3..1044f05324 100644 --- a/apps/desktop/installer/pages.nsh +++ b/apps/desktop/installer/pages.nsh @@ -18,6 +18,8 @@ Var InstallerEditFrame Var InstallerBrowse Var InstallerLaunch Var InstallerExpanded +Var InstallerMinimizeButton +Var InstallerCloseButton !include "${__FILEDIR__}\path.nsh" !include "${__FILEDIR__}\drawing.nsh" @@ -59,11 +61,13 @@ Function InstallerCreate !insertmacro InstallerControlColors $4 ${NSD_CreateButton} 0 0 0 0 "−" Pop $4 + StrCpy $InstallerMinimizeButton $4 !insertmacro InstallerPlace $4 504 8 40 32 ${NSD_OnClick} $4 InstallerMinimize ${NSD_OnNotify} $4 InstallerPaintButton ${NSD_CreateButton} 0 0 0 0 "×" Pop $4 + StrCpy $InstallerCloseButton $4 !insertmacro InstallerPlace $4 548 8 40 32 ${NSD_OnClick} $4 InstallerClose ${NSD_OnNotify} $4 InstallerPaintButton @@ -321,6 +325,13 @@ Function InstallerPaintButton ${If} $R1 != 0 StrCpy $R3 $InstallerControlHover ${EndIf} + ${If} $R0 == $InstallerMinimizeButton + ${OrIf} $R0 == $InstallerCloseButton + IntOp $R1 $R9 & 1 + ${If} $R1 != 0 + StrCpy $R3 $InstallerControlPressed + ${EndIf} + ${EndIf} ${EndIf} System::Call 'gdiplus::GdipCreateSolidFill(i R3, *p .R1)' System::Call 'gdiplus::GdipCreateFromHDC(p R4, *p .R5)' diff --git a/apps/desktop/installer/theme.nsh b/apps/desktop/installer/theme.nsh index a02214455c..a80c5761b0 100644 --- a/apps/desktop/installer/theme.nsh +++ b/apps/desktop/installer/theme.nsh @@ -22,6 +22,9 @@ !define INSTALLER_PRIMARY_HOVER 0xFF2D3135 !define INSTALLER_PRIMARY_PRESSED 0xFF000000 !define INSTALLER_CONTROL_HOVER 0xFFEEF0F2 +!define INSTALLER_CONTROL_PRESSED 0xFFD4DBE9 +!define INSTALLER_DARK_CONTROL_HOVER 0xFF303034 +!define INSTALLER_DARK_CONTROL_PRESSED 0xFF45454B !define INSTALLER_TRACK_COLOR 0xFFE9ECF2 !define INSTALLER_TEXT_COLORREF 0x15110F @@ -35,6 +38,7 @@ Var InstallerPrimary Var InstallerPrimaryHover Var InstallerPrimaryPressed Var InstallerControlHover +Var InstallerControlPressed Var InstallerTrack Var InstallerButtonText Var InstallerBorder @@ -70,7 +74,8 @@ Function InstallerResolveTheme StrCpy $InstallerPrimary 0xFFF9FAFB StrCpy $InstallerPrimaryHover 0xFFE9ECF2 StrCpy $InstallerPrimaryPressed 0xFFD4DBE9 - StrCpy $InstallerControlHover 0xFF303034 + StrCpy $InstallerControlHover ${INSTALLER_DARK_CONTROL_HOVER} + StrCpy $InstallerControlPressed ${INSTALLER_DARK_CONTROL_PRESSED} StrCpy $InstallerTrack 0xFF61666B StrCpy $InstallerButtonText 0x15110F StrCpy $InstallerBorder 0xFF61666B @@ -84,6 +89,7 @@ Function InstallerResolveTheme StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER} StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED} StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER} + StrCpy $InstallerControlPressed ${INSTALLER_CONTROL_PRESSED} StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR} StrCpy $InstallerButtonText 0xFFFFFF StrCpy $InstallerBorder 0xFFBBC0C8 diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 04d258357d..9c9a9e4f81 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -113,7 +113,24 @@ struct ProgressPage { UINT dpi; ULONG_PTR gdiplus; Image* brand; + Bitmap* buffer; + HDC bufferDc; + HBITMAP bufferBitmap; + HGDIOBJ previousBitmap; + UINT pressedCommand; + UINT hoveredCommand; + ARGB controlHover; + ARGB controlPressed; WCHAR captions[5][128]; + + ~ProgressPage() { + delete buffer; + delete brand; + if (previousBitmap) SelectObject(bufferDc, previousBitmap); + if (bufferBitmap) DeleteObject(bufferBitmap); + if (bufferDc) DeleteDC(bufferDc); + if (gdiplus) GdiplusShutdown(gdiplus); + } }; // NSIS shows its page after MUI's SHOW callback; keep its controls off screen. @@ -139,6 +156,45 @@ static void FillProgress(Graphics& graphics, Brush& brush, REAL width) { graphics.FillPath(&brush, &path); } +static UINT CaptionCommand(const ProgressPage& page, LPARAM position) { + const int x = static_cast(LOWORD(position)); + const int y = static_cast(HIWORD(position)); + if (y < 0 || y >= MulDiv(48, page.dpi, 96) || x < MulDiv(504, page.dpi, 96) || x >= MulDiv(600, page.dpi, 96)) return 0; + return x >= MulDiv(548, page.dpi, 96) ? SC_CLOSE : SC_MINIMIZE; +} + +static void PaintCaptionButtons(Graphics& graphics, const ProgressPage& page) { + SolidBrush background(page.dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); + graphics.FillRectangle(&background, 504, 0, 96, 48); + SolidBrush ink(page.dark ? Color(255, 255, 255, 255) : Color(255, 15, 17, 21)); + FontFamily family(L"Microsoft YaHei UI"); + Font font(&family, 16, FontStyleRegular, UnitPixel); + StringFormat centered; + centered.SetAlignment(StringAlignmentCenter); + centered.SetLineAlignment(StringAlignmentCenter); + for (UINT command : {SC_MINIMIZE, SC_CLOSE}) { + const int x = command == SC_MINIMIZE ? 504 : 548; + if (page.hoveredCommand == command && (!page.pressedCommand || page.pressedCommand == command)) { + const bool pressed = page.pressedCommand == command; + SolidBrush fill(Color(pressed ? page.controlPressed : page.controlHover)); + GraphicsPath path; + path.AddArc(x, 8, 20, 20, 180, 90); + path.AddArc(x + 20, 8, 20, 20, 270, 90); + path.AddArc(x + 20, 20, 20, 20, 0, 90); + path.AddArc(x, 20, 20, 20, 90, 90); + path.CloseFigure(); + graphics.FillPath(&fill, &path); + } + graphics.DrawString(command == SC_MINIMIZE ? L"\x2212" : L"\x00d7", -1, + &font, RectF(static_cast(x), 8, 40, 32), ¢ered, &ink); + } +} + +static void InvalidateCaptionButtons(HWND window, const ProgressPage& page) { + RECT bounds{MulDiv(504, page.dpi, 96), 0, MulDiv(600, page.dpi, 96), MulDiv(48, page.dpi, 96)}; + InvalidateRect(window, &bounds, FALSE); +} + static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { auto* page = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); if (message == WM_CREATE) { @@ -148,29 +204,68 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L } if (message == WM_ERASEBKGND) return 1; if (message == WM_TIMER) { InvalidateRect(window, nullptr, FALSE); return 0; } - if (message == WM_LBUTTONDOWN && page) { - const int x = LOWORD(lparam) * 96 / page->dpi; - const int y = HIWORD(lparam) * 96 / page->dpi; - if (y < 48) { - HWND parent = GetParent(window); - if (x >= 548) PostMessageW(parent, WM_CLOSE, 0, 0); - else if (x >= 504) ShowWindow(parent, SW_MINIMIZE); - else { ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); } + if (message == WM_MOUSEMOVE && page) { + TRACKMOUSEEVENT tracking{sizeof(tracking), TME_LEAVE, window, 0}; + TrackMouseEvent(&tracking); + const UINT hovered = CaptionCommand(*page, lparam); + if (page->hoveredCommand != hovered) { + page->hoveredCommand = hovered; + InvalidateCaptionButtons(window, *page); } return 0; } - if (message == WM_PAINT && page) { - PAINTSTRUCT paint; - HDC dc = BeginPaint(window, &paint); + if (message == WM_MOUSELEAVE && page) { + page->hoveredCommand = 0; + InvalidateCaptionButtons(window, *page); + return 0; + } + if (message == WM_LBUTTONDOWN && page) { + const int x = static_cast(LOWORD(lparam)); + const int y = static_cast(HIWORD(lparam)); + page->pressedCommand = CaptionCommand(*page, lparam); + if (page->pressedCommand) { + page->hoveredCommand = page->pressedCommand; + SetCapture(window); + InvalidateCaptionButtons(window, *page); + } + else if (x >= 0 && x < MulDiv(504, page->dpi, 96) && y >= 0 && y < MulDiv(48, page->dpi, 96)) { + HWND parent = GetParent(window); + ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); + } + return 0; + } + if (message == WM_LBUTTONUP && page) { + const UINT command = page->pressedCommand; + page->pressedCommand = 0; + page->hoveredCommand = CaptionCommand(*page, lparam); + InvalidateCaptionButtons(window, *page); + const bool activate = command && GetCapture() == window && CaptionCommand(*page, lparam) == command; + if (GetCapture() == window) ReleaseCapture(); + if (activate) { + if (command == SC_CLOSE) PostMessageW(GetParent(window), WM_CLOSE, 0, 0); + else ShowWindow(GetParent(window), SW_MINIMIZE); + } + return 0; + } + if ((message == WM_CAPTURECHANGED || message == WM_CANCELMODE) && page) { + page->pressedCommand = 0; + InvalidateCaptionButtons(window, *page); + if (message == WM_CANCELMODE && GetCapture() == window) ReleaseCapture(); + return 0; + } + if ((message == WM_PAINT || message == WM_PRINTCLIENT) && page) { + PAINTSTRUCT paint = {}; + const bool printing = message == WM_PRINTCLIENT; + HDC dc = printing ? reinterpret_cast(wparam) : BeginPaint(window, &paint); + if (printing) GetClientRect(window, &paint.rcPaint); { - Bitmap buffer(MulDiv(600, page->dpi, 96), MulDiv(600, page->dpi, 96), PixelFormat32bppPARGB); - Graphics graphics(&buffer); + Graphics graphics(page->buffer); graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f); - graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); graphics.SetSmoothingMode(SmoothingModeAntiAlias); - graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic); graphics.SetPixelOffsetMode(PixelOffsetModeHalf); - graphics.DrawImage(page->brand, Rect(0, 174, 600, 196)); + PaintCaptionButtons(graphics, *page); + SolidBrush background(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); + graphics.FillRectangle(&background, 48, 480, 504, 56); const int stage = static_cast(reinterpret_cast(GetPropW(GetParent(window), L"HarnessInstaller.Stage"))); const double fraction = reinterpret_cast(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0; page->progress.Advance(stage, fraction, GetTickCount64()); @@ -188,28 +283,25 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L wsprintfW(caption, page->captions[page->progress.CaptionStage()], percent); SetWindowTextW(window, caption); graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), ¢ered, &ink); - Font controls(&family, 16, FontStyleRegular, UnitPixel); - graphics.DrawString(L"\x2212", -1, &controls, RectF(504, 8, 40, 32), ¢ered, &ink); - graphics.DrawString(L"\x00d7", -1, &controls, RectF(548, 8, 40, 32), ¢ered, &ink); - Graphics screen(dc); - screen.DrawImage(&buffer, 0, 0); } - EndPaint(window, &paint); + BitBlt(dc, paint.rcPaint.left, paint.rcPaint.top, + paint.rcPaint.right - paint.rcPaint.left, paint.rcPaint.bottom - paint.rcPaint.top, + page->bufferDc, paint.rcPaint.left, paint.rcPaint.top, SRCCOPY); + if (!printing) EndPaint(window, &paint); if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast(100)); return 0; } if (message == WM_NCDESTROY && page) { KillTimer(window, 1); - delete page->brand; - GdiplusShutdown(page->gdiplus); delete page; } return DefWindowProcW(window, message, wparam, lparam); } // Runs on the NSIS UI thread; the stock installation section runs on its worker. +// Caption colors are opaque ARGB from NSIS; failure preserves the stock page's visibility. extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source, - BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, + BOOL dark, UINT dpi, ARGB controlHover, ARGB controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning) { HINSTANCE module = GetModuleHandleW(nullptr); WNDCLASSW type = {}; @@ -222,22 +314,56 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, if (!page) return nullptr; GdiplusStartupInput startup; if (GdiplusStartup(&page->gdiplus, &startup, nullptr) != Ok) { delete page; return nullptr; } - HWND stockPage = GetParent(source); - if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) { - GdiplusShutdown(page->gdiplus); delete page; return nullptr; - } - ShowWindow(stockPage, SW_HIDE); page->dark = dark != FALSE; page->dpi = dpi; + page->controlHover = controlHover; + page->controlPressed = controlPressed; const WCHAR* captions[] = {preparing, extracting, copying, registering, cleaning}; for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128); page->brand = new Image(brand); - if (page->brand->GetLastStatus() != Ok) { - delete page->brand; GdiplusShutdown(page->gdiplus); delete page; return nullptr; + if (!page->brand || page->brand->GetLastStatus() != Ok) { + delete page; return nullptr; } - HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE, + const int size = MulDiv(600, dpi, 96); + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = size; + info.bmiHeader.biHeight = -size; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* pixels = nullptr; + page->bufferDc = CreateCompatibleDC(nullptr); + page->bufferBitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0); + if (!page->bufferDc || !page->bufferBitmap) { delete page; return nullptr; } + page->previousBitmap = SelectObject(page->bufferDc, page->bufferBitmap); + if (!page->previousBitmap) { delete page; return nullptr; } + // Opaque pixels are shared by GDI+ drawing and the window's GDI blit. + page->buffer = new Bitmap(size, size, size * 4, PixelFormat32bppPARGB, static_cast(pixels)); + if (!page->buffer || page->buffer->GetLastStatus() != Ok) { delete page; return nullptr; } + { + // Static artwork stays in the buffer for this page's lifetime. + Graphics graphics(page->buffer); + graphics.ScaleTransform(dpi / 96.0f, dpi / 96.0f); + graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); + graphics.SetSmoothingMode(SmoothingModeAntiAlias); + graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic); + graphics.SetPixelOffsetMode(PixelOffsetModeHalf); + graphics.DrawImage(page->brand, Rect(0, 174, 600, 196)); + PaintCaptionButtons(graphics, *page); + } + delete page->brand; + page->brand = nullptr; + HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD, 0, 0, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), parent, nullptr, module, page); - if (!window) { delete page->brand; GdiplusShutdown(page->gdiplus); delete page; } + if (!window) { delete page; return nullptr; } + // Keep the stock page available until the replacement is fully constructed. + HWND stockPage = GetParent(source); + if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) { + DestroyWindow(window); return nullptr; + } + ShowWindow(stockPage, SW_HIDE); + ShowWindow(window, SW_SHOW); return window; } @@ -268,6 +394,8 @@ extern "C" __declspec(dllexport) BOOL __cdecl InstallerFinishProgress(HWND windo static LRESULT CALLBACK FrameProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR id, DWORD_PTR) { if (message == WM_NCCALCSIZE && wparam) return 0; + // Activation must not repaint the standard frame over the custom client area. + if (message == WM_NCACTIVATE) return DefSubclassProc(window, message, wparam, -1); if (message == WM_NCHITTEST) { const LRESULT hit = DefSubclassProc(window, message, wparam, lparam); // The installer has a fixed size; its page provides the drag area. @@ -291,7 +419,8 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window ShowWindow(child, SW_HIDE); } } - SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME); + // DWM retains native transitions; WM_NCCALCSIZE removes the visible caption. + SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME | WS_CAPTION); const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED; HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy)); if (FAILED(result)) return result; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1d44f92b93..64a6bff8fc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,6 +4,9 @@ "version": "0.2.1-rc.2", "private": true, "license": "MIT", + "author": { + "name": "Hangzhou DeepSeek Artificial Intelligence Co., Ltd." + }, "type": "module", "main": "lib/main.js", "scripts": { diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index af378a930a..96c2ee0cf0 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -51,8 +51,8 @@ en: a7aa3066e649ffe8 zh: 4334637910058c09 /desktop-packaging/installation-and-release-verification: - en: 0006b6b1ae4998f4 - zh: 2bab6918b0c37b25 + en: eb35eb973b796643 + zh: c1c52311632b7296 /desktop-packaging/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 6e2ec84cc8..a77d260591 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -274,6 +274,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Installation and release verification +Windows lists the installed application by product name without a version suffix; the version remains in the separate version field. Desktop's `package.json` `author.name` supplies the installer publisher and executable company name, independently of the signing certificate. Windows and macOS retain product-name copyright text. Installation or upgrade with a rebuilt installer applies the new metadata; existing installations retain their previous values until then. + Keep the generated installer, its version and packaging records together. Review signatures and runtime checks before installation, then follow the [installed-update qualification procedure](../tests/installed-update/README.md) for installed launch and upgrade evidence. The [Desktop test record](../tests/README.md) owns verification limits; a successful workflow alone does not establish production preset, new-workstation or installed-upgrade acceptance. ## Dev Note diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 3c8d101ecc..54e9d740f8 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -274,6 +274,8 @@ macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/ ## 安装与发布验收 +Windows 以不带版本后缀的产品名称显示已安装应用,版本保留在单独的版本字段中。Desktop 的 `package.json` 中的 `author.name` 提供安装包发布者和可执行文件公司名称,与签名证书相互独立。Windows 和 macOS 的版权文本仍使用产品名称。重新构建的安装包在安装或升级时写入新元数据;既有安装在此之前保留原值。 + 一起保留生成的安装包、版本和打包记录。安装前检查签名与运行时验证结果,再按[已安装更新验收流程](../tests/installed-update/README.zh.md)记录安装后启动及升级证据。[Desktop 测试记录](../tests/README.zh.md)维护验证限制;工作流成功本身不代表 production preset、新工作站或已安装升级验收通过。 ## 开发备注 diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index 24e1f88041..eed28f7718 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -21,6 +21,7 @@ function render(view) { 'stop-failed': messages.updateStopFailed, 'tasks-changed': messages.updateTasksChanged, 'tasks-unavailable': messages.updateTasksUnavailable, + 'fullscreen-timeout': messages.updateFullscreenTimeout, } const fallback = failed || update.phase === 'idle' || view.error !== undefined let title = policy.title ?? messages.mandatoryTitle diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 4b9cd27a8d..7dc39ff326 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -114,6 +114,8 @@ export function createElectronBuilderConfig( ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, }, productName: 'DeepSeek Harness', + // The default copyright uses author.name; this line names the product instead. + copyright: `Copyright © ${new Date().getFullYear()} \${productName}`, // Unsigned builds carry their own suffix so a shared file can never pass for a release artifact. artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${output.suffix}.\${ext}`, directories: { output: output.directory }, @@ -250,6 +252,7 @@ export function createElectronBuilderConfig( target: ['AppImage'], }, nsis: { + uninstallDisplayName: '${productName}', installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), include: fileURLToPath(new URL('./installer.nsh', import.meta.url)), diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 9de39d3ff2..365c5a45f2 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -134,7 +134,7 @@ async function main(): Promise { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])) const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) const targetName = resolveDesktopBuildTarget() - const target = { platform: process.platform, arch: desktopTargetPlatform(targetName).arch } + const target = desktopTargetPlatform(targetName) const modules = join(BUILD_ROOT, 'node_modules') const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8')) const officeEngine = selectOfficeEngine(officeManifest, target) diff --git a/apps/desktop/scripts/prepare-package-set.ts b/apps/desktop/scripts/prepare-package-set.ts index 7e7d62eea2..4cffa7018b 100644 --- a/apps/desktop/scripts/prepare-package-set.ts +++ b/apps/desktop/scripts/prepare-package-set.ts @@ -25,7 +25,7 @@ import { } from '../src/core-package-set.ts' import { capture } from '../../../scripts/release/process.ts' import { tarballFiles } from '../../../scripts/release/tarball.ts' -import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const DSH_PACKAGE = '@deepseek-ai/dsh' const ROOT_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const @@ -41,6 +41,17 @@ export interface PackedDesktopPackage { readonly manifest: Readonly> } +function supportsTarget(manifest: Readonly>, field: 'os' | 'cpu', target: string): boolean { + const values = manifest[field] + if (values === undefined) return true + if (!Array.isArray(values) || !values.every((value): value is string => typeof value === 'string')) { + throw new Error(`desktop package set: ${String(manifest.name)} has invalid ${field}`) + } + if (values.includes(`!${target}`)) return false + return values.length === 0 || (values.length === 1 && values[0] === 'any') || values.includes(target) + || values.every(value => value.startsWith('!')) +} + function dependencyNames(manifest: Readonly>, section: string): string[] { const value = manifest[section] if (value === undefined) return [] @@ -54,10 +65,12 @@ function dependencyNames(manifest: Readonly>, section: s * Select workspace dependencies rooted at dsh and its private Host; npm resolves external packages. * Reads the repository workspace manifest and package manifests to distinguish required local packages from npm-resolved externals. * @param available - Packed packages indexed by package name. + * @param target - Platform and architecture of the installer payload. * @returns Selected packages sorted by name. */ export function selectDesktopPackageClosure( available: ReadonlyMap, + target: ReturnType, ): PackedDesktopPackage[] { const workspace = yaml.load(readFileSync(join(REPOSITORY_ROOT, 'pnpm-workspace.yaml'), 'utf8')) as { packages: string[] } const workspaceNames = new Set(globSync(workspace.packages.map(pattern => `${pattern}/package.json`), { cwd: REPOSITORY_ROOT }) @@ -77,7 +90,9 @@ export function selectDesktopPackageClosure( } } for (const dependency of dependencyNames(packed.manifest, OPTIONAL_DEPENDENCY_SECTION)) { - if (available.has(dependency)) visit(dependency) + const optional = available.get(dependency) + if (optional && supportsTarget(optional.manifest, 'os', target.platform) + && supportsTarget(optional.manifest, 'cpu', target.arch)) visit(dependency) } } for (const name of ROOT_PACKAGES) { @@ -127,9 +142,16 @@ export function assertDesktopHostPackageFiles(files: readonly string[]): void { } } -/** Prepare a package set from release tarball directories. */ -export function prepareDesktopPackageSet(inputs: readonly string[], output: string): void { - const selected = selectDesktopPackageClosure(packedPackages(inputs)) +/** + * Prepare a package set from release tarball directories. + * @param inputs - Release tarball directories. + * @param output - Destination package-set directory. + * @param target - Platform and architecture of the installer payload. + */ +export function prepareDesktopPackageSet( + inputs: readonly string[], output: string, target: ReturnType, +): void { + const selected = selectDesktopPackageClosure(packedPackages(inputs), target) const host = selected.find(packed => packed.manifest.name === DESKTOP_HOST_PACKAGE) if (host === undefined) throw new Error(`desktop package set: selected closure omits ${DESKTOP_HOST_PACKAGE}`) assertDesktopHostPackageFiles(tarballFiles(host.tarball)) @@ -171,7 +193,7 @@ function main(): void { }) const inputs = (values.from ?? defaultInputs).map(path => resolve(REPOSITORY_ROOT, path)) const output = values.out === undefined ? buildPaths.packageSet : resolve(REPOSITORY_ROOT, values.out) - prepareDesktopPackageSet(inputs, output) + prepareDesktopPackageSet(inputs, output, desktopTargetPlatform(resolveDesktopBuildTarget())) console.log(`desktop package set: prepared ${output}`) } diff --git a/apps/desktop/scripts/prepare-windows-installer.ps1 b/apps/desktop/scripts/prepare-windows-installer.ps1 index 98e02cf9a6..5195891033 100644 --- a/apps/desktop/scripts/prepare-windows-installer.ps1 +++ b/apps/desktop/scripts/prepare-windows-installer.ps1 @@ -14,7 +14,7 @@ $visualStudio = & $vswhere -latest -products '*' -requires Microsoft.VisualStudi if (-not $visualStudio) { throw 'Visual Studio C++ Build Tools are missing.' } $vcvars = Join-Path $visualStudio 'VC/Auxiliary/Build/vcvars32.bat' $compileScript = Join-Path $output 'compile-frame.cmd' -$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib'))) +$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib gdi32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib'))) [IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default) & $env:ComSpec /d /c $compileScript if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' } @@ -50,6 +50,12 @@ if ($TestProgress -or $CompileProgressOnly) { & $presentationExecutable if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' } } + $renderSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-render.cpp')) + $renderExecutable = Join-Path $output 'render-test.exe' + $renderScript = Join-Path $output 'compile-render-test.cmd' + [IO.File]::WriteAllLines($renderScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link "{3}" user32.lib gdi32.lib' -f $renderSource, (Join-Path $output 'render-test.obj'), $renderExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default) + & $env:ComSpec /d /c $renderScript + if ($LASTEXITCODE -ne 0) { throw 'Installer render test compilation failed.' } $reportSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-extract-report.cpp')) $reportExecutable = Join-Path $output 'extract-report-test.exe' $reportScript = Join-Path $output 'compile-extract-report-test.cmd' diff --git a/apps/desktop/scripts/test-windows-installer.mjs b/apps/desktop/scripts/test-windows-installer.mjs index 03b601b187..27c6381a95 100644 --- a/apps/desktop/scripts/test-windows-installer.mjs +++ b/apps/desktop/scripts/test-windows-installer.mjs @@ -58,10 +58,12 @@ try { join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions) const progressTest = join(output, 'ui', 'progress-test.exe') const presentationTest = join(output, 'ui', 'presentation-test.exe') + const renderTest = join(output, 'ui', 'render-test.exe') const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe') if (sign) { await sign({ path: progressTest, hash: 'sha256', isNest: false }) await sign({ path: presentationTest, hash: 'sha256', isNest: false }) + await sign({ path: renderTest, hash: 'sha256', isNest: false }) await sign({ path: cleanupTest, hash: 'sha256', isNest: false }) await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false }) installWindowsNsisBootstrapSigner({ sign }) @@ -69,6 +71,23 @@ try { if (!uninstallOnly) { await execute(progressTest, [], childOptions) await execute(presentationTest, [], childOptions) + const renderResults = [] + const themeSource = await readFile(join(appRoot, 'installer', 'theme.nsh'), 'utf8') + const captionColor = (name) => { + const match = themeSource.match(new RegExp(`^!define ${name} (0x[0-9A-Fa-f]{8})\\r?$`, 'm')) + if (!match) throw new Error(`Missing installer caption color: ${name}`) + return match[1] + } + for (const dpi of [96, 192]) { + for (const theme of ['light', 'dark']) { + const brand = theme === 'dark' ? 'brand-dark-2x.bmp' : 'brand-2x.bmp' + const prefix = theme === 'dark' ? 'INSTALLER_DARK_CONTROL' : 'INSTALLER_CONTROL' + const result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme, + captionColor(`${prefix}_HOVER`), captionColor(`${prefix}_PRESSED`)], childOptions) + renderResults.push(result.stdout) + } + } + await writeFile(join(output, 'render-results.log'), renderResults.join('')) } await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions) const payloadSource = join(output, 'payload.nsi') diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index fc19021fa6..ffa7884a98 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -34,7 +34,7 @@ export const DESKTOP_IPC = { } as const /** Desktop release update state rendered by desktop-owned UI. */ -export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' +export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' | 'fullscreen-timeout' export interface DesktopUpdateState { readonly phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' | 'ready' | 'error' @@ -59,6 +59,7 @@ export type DesktopUpdateFailureKind = | 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' + | 'fullscreen-timeout' /** Semantic status content; actions open main-process confirmation dialogs only. */ export interface DesktopUpdatePresentation { diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 5d9d4607c7..3c60af45d8 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -128,6 +128,7 @@ export const en = { updateStopTasks: 'Stop tasks and update', updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.', updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.', + updateFullscreenTimeout: 'Could not exit fullscreen. Exit fullscreen, then retry the update.', updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.', updateTechnicalDetails: 'View technical details', updateTitle: 'DeepSeek Harness Update', @@ -299,6 +300,7 @@ export const zh = { updateStopTasks: '停止任务并更新', updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。', updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。', + updateFullscreenTimeout: '无法退出全屏,请退出全屏后重试更新', updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。', updateTechnicalDetails: '查看技术详情', updateTitle: 'DeepSeek Harness 更新', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2afdfd7194..af531e9d1d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,6 +35,7 @@ import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLoc import { claimDesktopSingleInstance } from './single-instance.ts' import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' +import { prepareDesktopUpdateWindow } from './update-window.ts' import { DesktopCommandManager } from './command-management.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' @@ -595,6 +596,8 @@ async function main(): Promise { const host = backend.host if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable) const active = await host.updateTasks('inspect') + const parent = currentDialogWindow() + if (parent === undefined || !await prepareDesktopUpdateWindow(parent, locale.messages.updateFullscreenTimeout)) return false const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform) const confirmation: Electron.MessageBoxOptions = { type: active ? 'warning' : 'info', title: locale.messages.updateTitle, @@ -606,8 +609,6 @@ async function main(): Promise { if (isMandatory()) { if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false } else { - const parent = currentDialogWindow() - if (parent === undefined) return false const result = await updateDialog.show(parent, confirmation) if (result.response !== 0 || isMandatory()) return false } diff --git a/apps/desktop/src/update-presentation.ts b/apps/desktop/src/update-presentation.ts index 42119d93a2..670b6eb4b6 100644 --- a/apps/desktop/src/update-presentation.ts +++ b/apps/desktop/src/update-presentation.ts @@ -33,6 +33,7 @@ export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: D 'stop-failed': messages.updateStopFailed, 'tasks-changed': messages.updateTasksChanged, 'tasks-unavailable': messages.updateTasksUnavailable, + 'fullscreen-timeout': messages.updateFullscreenTimeout, } return summaries[kind] } diff --git a/apps/desktop/src/update-window.ts b/apps/desktop/src/update-window.ts new file mode 100644 index 0000000000..6a695d3cbd --- /dev/null +++ b/apps/desktop/src/update-window.ts @@ -0,0 +1,34 @@ +/** Native window preparation before an update confirmation can stop the Host. */ +import type { BrowserWindow } from 'electron' +import { DesktopUpdatePreparationError } from './update-error.ts' + +type UpdateWindow = Pick & Pick + +const FULLSCREEN_EXIT_TIMEOUT_MS = 5000 + +/** + * Leave macOS fullscreen before displaying installation confirmation. + * @param window - Product window that owns the update confirmation. + * @param timeoutMessage - Locale-owned guidance to exit fullscreen and retry. + * @returns Whether the window survived the completed native transition; destruction cancels installation. + * @throws {DesktopUpdatePreparationError} If macOS does not report completion within five seconds. + */ +export async function prepareDesktopUpdateWindow(window: UpdateWindow, timeoutMessage: string): Promise { + if (window.isDestroyed()) return false + if (process.platform !== 'darwin' || !window.isFullScreen()) return true + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(deadline) + window.off('leave-full-screen', left) + window.off('closed', closed) + } + const left = (): void => { cleanup(); resolve(true) } + const closed = (): void => { cleanup(); resolve(false) } + // Bound a missing native completion event; elapsed time never authorizes installation. + const deadline = setTimeout(() => { cleanup(); reject(new DesktopUpdatePreparationError('fullscreen-timeout', timeoutMessage)) }, FULLSCREEN_EXIT_TIMEOUT_MS) + window.once('leave-full-screen', left) + window.once('closed', closed) + try { window.setFullScreen(false) } + catch (error) { cleanup(); throw error } + }) +} diff --git a/apps/desktop/tests/cua-optional-package.spec.ts b/apps/desktop/tests/cua-optional-package.spec.ts new file mode 100644 index 0000000000..3f3f7ad572 --- /dev/null +++ b/apps/desktop/tests/cua-optional-package.spec.ts @@ -0,0 +1,121 @@ +/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */ +import { createHash } from 'node:crypto' +import { createServer } from 'node:http' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { execa } from 'execa' +import { expect, it, onTestFinished } from 'vitest' +import { pnpmCommand } from '../../../scripts/release/process.ts' + +const repository = resolve(import.meta.dirname, '../../..') +const providerName = '@deepseek-ai/dsh-experimental-cua-macos' +const bundleName = '@deepseek-ai/dsh-cua-bundle' +const readManifest = async (path: string): Promise> => + JSON.parse(await readFile(join(repository, path, 'package.json'), 'utf8')) as Record + +it('fetches the macOS optional tarball only for Darwin, with an empty cache for every target', async () => { + const root = await mkdtemp(join(tmpdir(), 'cua-cold-install-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const userconfig = join(root, 'npmrc') + await writeFile(userconfig, '') + const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !/KEY|SECRET|TOKEN|PASSWORD/iu.test(key) && !/^(npm|pnpm|corepack)_/iu.test(key) + && key !== 'NODE_OPTIONS' && key !== 'NODE_PATH')) + const [pnpm, ...prefix] = pnpmCommand() + const command = async (args: string[], cwd: string) => { + const result = await execa(pnpm, [...prefix, `--config.userconfig=${userconfig}`, ...args], { + cwd, env: { ...environment, CI: 'true', XDG_CACHE_HOME: join(cwd, 'xdg-cache'), + XDG_DATA_HOME: join(cwd, 'xdg-data'), XDG_STATE_HOME: join(cwd, 'xdg-state'), + XDG_CONFIG_HOME: join(cwd, 'xdg-config') }, extendEnv: false, reject: false, timeout: 60_000, + }) + expect(result.timedOut, result.stderr).toBe(false) + expect(result.signal, result.stderr).toBeUndefined() + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0) + return result.stdout + } + const cli = await readManifest('apps/cli') + const bundle = await readManifest('packages/experimental/cua-bundle') + const native = await readManifest('packages/experimental/cua-macos') + expect(cli.dependencies).toHaveProperty(bundleName) + expect(bundle.dependencies).not.toHaveProperty(providerName) + expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' }) + expect(native.os).toEqual(['darwin']) + const version = '1.0.0' + const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record).map(name => [name, version])) + const manifests = [ + { name: cli.name, version, dependencies: { [bundleName]: version } }, + { name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } }, + ...Object.keys(bundleDependencies).map(name => ({ name, version })), + { name: providerName, version, os: native.os, files: native.files }, + ] + const packages = new Map; bytes: Buffer }>() + for (const manifest of manifests) { + const name = String(manifest.name) + const directory = join(root, name.replace('@deepseek-ai/', '')) + await mkdir(directory) + await writeFile(join(directory, 'package.json'), JSON.stringify(manifest)) + if (name === providerName) { + await mkdir(join(directory, 'native/bin/darwin-arm64'), { recursive: true }) + for (const file of ['.gitignore', '.npmignore']) { + await writeFile(join(directory, 'native', file), await readFile(join(repository, 'packages/experimental/cua-macos/native', file))) + } + await writeFile(join(directory, 'native/bin/darwin-arm64/dsh-cua-native'), 'inert native payload') + } + await command(['pack', '--out', 'package.tgz'], directory) + const bytes = await readFile(join(directory, 'package.tgz')) + packages.set(name, { manifest, bytes }) + if (name === providerName) { + const result = await execa('tar', ['-tzf', join(directory, 'package.tgz')]) + expect(result.stdout).toContain('package/native/bin/darwin-arm64/dsh-cua-native') + } + } + const requests: string[] = [] + let unrestricted = false + const server = createServer((request, response) => { + const path = decodeURIComponent(request.url ?? '') + requests.push(path) + const tarball = path.endsWith('.tgz') + const name = path.slice(1).replace(/\.tgz$/u, '') + const entry = packages.get(name) + if (!entry) { response.writeHead(404); response.end(); return } + if (tarball) { response.end(entry.bytes); return } + const manifest = { ...entry.manifest } + // pnpm also infers macOS from this package name; an explicit any overrides that inference. + if (unrestricted && name === providerName) manifest.os = ['any'] + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify({ name, 'dist-tags': { latest: version }, versions: { + [version]: { ...manifest, dist: { + tarball: `http://${request.headers.host}/${name}.tgz`, + integrity: `sha512-${createHash('sha512').update(entry.bytes).digest('base64')}`, + } }, + } })) + }) + onTestFinished(async () => { + server.closeAllConnections() + await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) }) + }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address') + for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) { + unrestricted = target === 'linux-unrestricted' + const project = join(root, target) + await mkdir(project) + await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } })) + await writeFile(join(project, 'pnpm-workspace.yaml'), [ + 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`, + ' cpu: [arm64]', 'minimumReleaseAge: 0', '', + ].join('\n')) + requests.length = 0 + const output = await command([ + 'install', '--ignore-scripts', '--config.enable-global-virtual-store=false', + `--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`, + `--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`, + ], project) + expect(requests).toContain(`/${bundleName}.tgz`) + expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz') + expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`) + .toBe(target === 'darwin' || unrestricted) + } +}, 120_000) diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 15a4610d98..d971bb262d 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -1,7 +1,8 @@ import { tmpdir } from 'node:os' import { readFileSync } from 'node:fs' import { Arch, Platform } from 'electron-builder' -import { Packager } from 'app-builder-lib' +import { AppInfo, Packager } from 'app-builder-lib' +import { expandMacro } from 'app-builder-lib/out/util/macroExpander.js' import { describe, expect, it, vi } from 'vitest' const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => undefined) })) @@ -19,7 +20,7 @@ describe('installer preparation preserves application dependencies', () => { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.com', }, platform, 'x64')).toThrow('DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN') }) - it.each(['win32', 'darwin'] as const)('keeps electron-builder responsible for node_modules on %s', async (platform) => { + it.each(['win32', 'darwin'] as const)('preserves installer metadata and dependency ownership on %s', async (platform) => { execute.mockClear() const env = { DSH_DESKTOP_APP_ID: 'com.example.installer', @@ -37,6 +38,18 @@ describe('installer preparation preserves application dependencies', () => { try { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(env, platform, 'x64') + const metadataPackager = new Packager({ projectDir: tmpdir() }) + const metadata: unknown = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + Object.defineProperties(metadataPackager, { + config: { value: config }, + metadata: { value: metadata }, + }) + const appInfo = new AppInfo(metadataPackager, undefined) + expect(appInfo.companyName).toBe('Hangzhou DeepSeek Artificial Intelligence Co., Ltd.') + expect(appInfo.copyright).toBe(`Copyright © ${new Date().getFullYear()} DeepSeek Harness`) + if (platform === 'win32') { + expect(expandMacro(config.nsis.uninstallDisplayName, null, appInfo, {}, false)).toBe('DeepSeek Harness') + } const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png') expect(aboutIcon).toBeDefined() expect(readFileSync(aboutIcon!.from)).toEqual(readFileSync(new URL('../resources/icon-windows.png', import.meta.url))) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index d9a8f3103b..e05c16b063 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1786,6 +1786,84 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() }) + it.each([false, true])('waits for macOS to leave fullscreen before installation confirmation, mandatory: %s', async (mandatory) => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) + if (mandatory) { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { + show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', + } }))) + } + const host = await readyForUpdate() + if (mandatory) await harness.policyBlocked.promise + const window = harness.windows[0]! + const dialogOwner = mandatory ? harness.windows.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window + const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') } + window.fullscreen = true + window.setFullScreen.mockImplementation(() => {}) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) }) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + if (mandatory) expect(harness.handlers.get(MANDATORY_IPC.status)!({ sender: dialogOwner.webContents, senderFrame: dialogOwner.webContents.mainFrame })).not.toHaveProperty('confirmation') + expect(host.stop).not.toHaveBeenCalled() + expect(host.updateTasks.mock.calls).toEqual([['inspect']]) + window.fullscreen = false + window.emit('leave-full-screen') + if (mandatory) await answerMandatory('install', dialogOwner) + await host.stopping.promise + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave) + expect(window.listenerCount('closed')).toBe(baseline.closed) + }) + + it('keeps the Host running after a fullscreen timeout and accepts a later retry', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) + const host = await readyForUpdate() + const window = harness.windows[0]! + window.fullscreen = true + window.setFullScreen.mockImplementation(() => {}) + const preparing = harness.prepareUpdate() + const rejected = expect(preparing).rejects.toMatchObject({ kind: 'fullscreen-timeout', + message: 'Could not exit fullscreen. Exit fullscreen, then retry the update.' }) + await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) }) + await vi.advanceTimersByTimeAsync(5000) + await rejected + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(host.updateTasks.mock.calls).toEqual([['inspect']]) + expect(host.stop).not.toHaveBeenCalled() + window.setFullScreen.mockImplementation((flag) => { window.fullscreen = flag; window.emit('leave-full-screen') }) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const retrying = harness.prepareUpdate() + await host.stopping.promise + host.exited.resolve() + await expect(retrying).resolves.toBe(true) + }) + + it.each([false, true])('cancels installation when the fullscreen product window closes, mandatory: %s', async (mandatory) => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) + if (mandatory) { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { + show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', + } }))) + } + const host = await readyForUpdate() + if (mandatory) await harness.policyBlocked.promise + const window = harness.windows[0]! + const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') } + window.fullscreen = true + window.setFullScreen.mockImplementation(() => {}) + const preparing = harness.prepareUpdate() + await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) }) + window.destroy() + await expect(preparing).resolves.toBe(false) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(host.stop).not.toHaveBeenCalled() + expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave) + }) + it('waits for Platform storage cleanup before the installer takes over', async () => { const host = await readyForUpdate() harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) @@ -1830,8 +1908,7 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() }) - async function answerMandatory(action: 'install' | 'later') { - const modal = harness.windows[0]! + async function answerMandatory(action: 'install' | 'later', modal = harness.windows[0]!) { const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } await vi.waitFor(() => { expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation') diff --git a/apps/desktop/tests/prepare-package-set.spec.ts b/apps/desktop/tests/prepare-package-set.spec.ts index 6db1ccbd01..c1b5edbd69 100644 --- a/apps/desktop/tests/prepare-package-set.spec.ts +++ b/apps/desktop/tests/prepare-package-set.spec.ts @@ -1,6 +1,12 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DESKTOP_HOST_RUNTIME_FILES, DESKTOP_PACKAGES_DIR } from '../src/core-package-set.ts' import { assertDesktopHostPackageFiles, + prepareDesktopPackageSet, selectDesktopPackageClosure, type PackedDesktopPackage, } from '../scripts/prepare-package-set.ts' @@ -37,7 +43,7 @@ describe('desktop package-set selection', () => { ['@deepseek-ai/platform-package', packed('@deepseek-ai/platform-package')], ['@deepseek-ai/unused', packed('@deepseek-ai/unused')], ]) - expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([ + expect(selectDesktopPackageClosure(available, { platform: 'win32', arch: 'x64' }).map(entry => entry.manifest.name)).toEqual([ '@deepseek-ai/cordis', '@deepseek-ai/dsh', '@deepseek-ai/dsh-base', @@ -57,10 +63,10 @@ describe('desktop package-set selection', () => { dependencies: { '@deepseek-ai/dsh': '^1.0.0' }, })], ]) - expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked package/u) + expect(() => selectDesktopPackageClosure(available, { platform: 'win32', arch: 'x64' })).toThrow(/unpacked package/u) expect(() => selectDesktopPackageClosure(new Map([ ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')], - ]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u) + ]), { platform: 'win32', arch: 'x64' })).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u) }) it('leaves independently published Office packages to npm resolution', () => { @@ -73,7 +79,7 @@ describe('desktop package-set selection', () => { })], ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host')], ]) - expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([ + expect(selectDesktopPackageClosure(available, { platform: 'win32', arch: 'x64' }).map(entry => entry.manifest.name)).toEqual([ '@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', ]) }) @@ -92,3 +98,67 @@ describe('desktop package-set selection', () => { expect(() => { assertDesktopHostPackageFiles(files.slice(0, 1)) }).toThrow(/lib\/cli\.js/u) }) }) + +const macProvider = '@deepseek-ai/dsh-experimental-cua-macos' +const cuaBundle = '@deepseek-ai/dsh-cua-bundle' +const targetManifests = () => new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { dependencies: { [cuaBundle]: '1.0.0' } })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host')], + [cuaBundle, packed(cuaBundle, { + optionalDependencies: { [macProvider]: '1.0.0' }, dependencies: { '@deepseek-ai/dsh-cua': '1.0.0' }, + })], + ['@deepseek-ai/dsh-cua', packed('@deepseek-ai/dsh-cua')], + [macProvider, packed(macProvider, { os: ['darwin'] })], +]) + +it.each([ + ['darwin', 'arm64', true], ['darwin', 'x64', true], ['win32', 'x64', false], +] as const)('selects the CUA native tarball for target %s/%s independently of the host', (platform, arch, included) => { + const selected = selectDesktopPackageClosure(targetManifests(), { platform, arch }).map(entry => entry.manifest.name) + expect(selected.includes(macProvider)).toBe(included) + expect(selected).toContain('@deepseek-ai/dsh-cua') + expect(selected).toContain(cuaBundle) +}) + +it.each([ + [{ cpu: ['arm64'] }, false], [{ os: ['!win32'] }, false], [{ os: ['!linux'] }, true], + [{ os: [] }, true], [{ os: ['any'] }, true], [{ os: ['any', '!win32'] }, false], + [{ cpu: ['x64'] }, true], +])('honors optional package platform constraints %j', (constraints, included) => { + const packages = targetManifests() + packages.set(macProvider, packed(macProvider, constraints)) + expect(selectDesktopPackageClosure(packages, { platform: 'win32', arch: 'x64' }) + .some(entry => entry.manifest.name === macProvider)).toBe(included) +}) + +it.each([{ os: 'darwin' }, { cpu: [42] }])('rejects malformed platform constraints %j', (constraints) => { + const packages = targetManifests() + packages.set(macProvider, packed(macProvider, constraints)) + expect(() => selectDesktopPackageClosure(packages, { platform: 'win32', arch: 'x64' })).toThrow(/has invalid/u) +}) + +it('copies no macOS tarball into the Windows package set', () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-platform-packages-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + const inputs = join(root, 'inputs') + mkdirSync(inputs) + for (const [name, entry] of targetManifests()) { + const stem = name.replace('@deepseek-ai/', '') + const folder = join(root, stem, 'package') + mkdirSync(folder, { recursive: true }) + writeFileSync(join(folder, 'package.json'), JSON.stringify(entry.manifest)) + if (name === '@deepseek-ai/dsh-desktop-host') { + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + mkdirSync(join(folder, file, '..'), { recursive: true }) + writeFileSync(join(folder, file), '') + } + } + execFileSync('tar', ['-czf', join(inputs, `${stem}.tgz`), '-C', join(folder, '..'), 'package']) + } + const output = join(root, 'output') + prepareDesktopPackageSet([inputs], output, { platform: 'win32', arch: 'x64' }) + expect(readdirSync(join(output, DESKTOP_PACKAGES_DIR))).not.toContain('dsh-experimental-cua-macos.tgz') + prepareDesktopPackageSet([inputs], output, { platform: 'darwin', arch: 'arm64' }) + expect(readFileSync(join(output, DESKTOP_PACKAGES_DIR, 'dsh-experimental-cua-macos.tgz'))) + .toEqual(readFileSync(join(inputs, 'dsh-experimental-cua-macos.tgz'))) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 1c6e9da1c7..3aef0bc60f 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -73,13 +73,13 @@ function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alp } describe('desktop update coordinator', () => { - it('keeps safe preparation diagnostics separate and clears them on an explicit retry', async () => { + it.each(['stop-failed', 'fullscreen-timeout'] as const)('keeps safe %s diagnostics separate and clears them on an explicit retry', async (kind) => { const f = fixture() await f.coordinator.check() await f.coordinator.download('1.1.0-rc.2') - f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError('stop-failed', zh.updateStopFailed, 'exit 0; shutdown acknowledged false')) + f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError(kind, zh.updateStopFailed, 'exit 0; shutdown acknowledged false')) expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'error', version: '1.1.0-rc.2', - failedOperation: 'install', preparationFailure: 'stop-failed', message: zh.updateStopFailed, + failedOperation: 'install', preparationFailure: kind, message: zh.updateStopFailed, technicalDetails: 'exit 0; shutdown acknowledged false' }) expect(f.quitAndInstall).not.toHaveBeenCalled() f.beforeRestart.mockResolvedValueOnce(false) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index 54bc5d5e2e..b2778c3b15 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -243,3 +243,19 @@ it.each(['en', 'zh-CN'])('records mandatory update guidance and actions across i }) await expect(JSON.stringify(snapshots, null, 2) + '\n').toMatchFileSnapshot(`./expected/update-guidance-${language}.json`) }) + +it.each(['en', 'zh-CN'])('shows fullscreen recovery guidance directly in the mandatory update page: %s', async (language) => { + const p = page('mandatory-update') + const locale = resolveDesktopLocale(language) + const state: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false }, + update: { phase: 'error', failedOperation: 'install', preparationFailure: 'fullscreen-timeout', + version: '0.1.6-nightly.1', message: 'different shell locale' } } + const action = vi.fn(async () => {}) + Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: { + status: async () => state, action, subscribe: () => () => {}, + } satisfies MandatoryUpdateApi }) + p.run() + await expect.poll(() => p.element('error').textContent).toBe(locale.messages.updateFullscreenTimeout) + expect((p.element('technical-details') as HTMLDetailsElement).open).toBe(false) + expect(action).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/update-presentation.spec.ts b/apps/desktop/tests/update-presentation.spec.ts index 9d3ce5009e..9c53a9ebec 100644 --- a/apps/desktop/tests/update-presentation.spec.ts +++ b/apps/desktop/tests/update-presentation.spec.ts @@ -32,6 +32,7 @@ it('keeps raw diagnostics out of error tooltips in both locales', () => { expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure: 'stop-failed', message: 'translated or changed copy', technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateStopFailed) for (const [preparationFailure, expected] of [ + ['fullscreen-timeout', messages.updateFullscreenTimeout], ['tasks-changed', messages.updateTasksChanged], ['tasks-unavailable', messages.updateTasksUnavailable], ] as const) { expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure, diff --git a/apps/desktop/tests/update-window.spec.ts b/apps/desktop/tests/update-window.spec.ts new file mode 100644 index 0000000000..bc1cf2dba7 --- /dev/null +++ b/apps/desktop/tests/update-window.spec.ts @@ -0,0 +1,69 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' +import { prepareDesktopUpdateWindow } from '../src/update-window.ts' +import { en } from '../src/locale.ts' + +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) + +function setup(platform = 'darwin', fullscreen = true, destroyed = false) { + vi.stubGlobal('process', { ...process, platform }) + const window = Object.assign(new EventEmitter(), { + isDestroyed: () => destroyed, + isFullScreen: () => fullscreen, + setFullScreen: vi.fn(), + }) + return { window, prepare: () => prepareDesktopUpdateWindow(window, en.updateFullscreenTimeout) } +} + +it.each(['darwin', 'win32', 'linux'])('does not transition an ordinary %s window', async (platform) => { + const f = setup(platform, false) + await expect(f.prepare()).resolves.toBe(true) + expect(f.window.setFullScreen).not.toHaveBeenCalled() +}) + +it('preserves Windows fullscreen', async () => { + const windows = setup('win32') + await expect(windows.prepare()).resolves.toBe(true) + expect(windows.window.setFullScreen).not.toHaveBeenCalled() +}) + +it('refuses a destroyed product window', async () => { + const destroyed = setup('darwin', true, true) + await expect(destroyed.prepare()).resolves.toBe(false) + expect(destroyed.window.setFullScreen).not.toHaveBeenCalled() +}) + +it.each(['leave-full-screen', 'closed'])('settles on %s and releases both listeners and the deadline', async (event) => { + vi.useFakeTimers() + const f = setup() + const preparing = f.prepare() + expect(f.window.setFullScreen).toHaveBeenCalledWith(false) + f.window.emit(event) + await expect(preparing).resolves.toBe(event === 'leave-full-screen') + expect(f.window.eventNames()).toEqual([]) + expect(vi.getTimerCount()).toBe(0) +}) + +it('rejects a stalled native transition and allows a later attempt to complete', async () => { + vi.useFakeTimers() + const f = setup() + const failure = expect(f.prepare()).rejects.toMatchObject({ kind: 'fullscreen-timeout', message: en.updateFullscreenTimeout }) + await vi.advanceTimersByTimeAsync(5000) + await failure + expect(f.window.eventNames()).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + const retry = f.prepare() + f.window.emit('leave-full-screen') + await expect(retry).resolves.toBe(true) + expect(vi.getTimerCount()).toBe(0) +}) + +it('releases native listeners when requesting the transition throws', async () => { + vi.useFakeTimers() + const f = setup() + const nativeError = new Error('native window unavailable') + f.window.setFullScreen.mockImplementation(() => { throw nativeError }) + await expect(f.prepare()).rejects.toBe(nativeError) + expect(f.window.eventNames()).toEqual([]) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/apps/desktop/tests/windows-installer-render.cpp b/apps/desktop/tests/windows-installer-render.cpp new file mode 100644 index 0000000000..8d9d8ef91e --- /dev/null +++ b/apps/desktop/tests/windows-installer-render.cpp @@ -0,0 +1,210 @@ +// Exercises the shipped progress window without extracting or installing a payload. +#define WIN32_LEAN_AND_MEAN +#define UNICODE +#define NOMINMAX +#include +#include +#include +#include + +extern "C" __declspec(dllimport) HRESULT __cdecl InstallerApplyFrame(HWND window); +extern "C" __declspec(dllimport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source, + BOOL dark, UINT dpi, DWORD controlHover, DWORD controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, + const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning); +extern "C" __declspec(dllimport) BOOL __cdecl InstallerFinishProgress(HWND window); + +static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + if (message == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(window, message, wparam, lparam); +} + +int wmain(int argc, wchar_t** argv) { + if (argc < 6) { + std::fprintf(stderr, "Usage: render-test.exe [interactive-title]\n"); + return 1; + } + const int dpi = _wtoi(argv[2]); + if (dpi < 96 || dpi > 384) return 2; + const bool dark = wcscmp(argv[3], L"dark") == 0; + if (!dark && wcscmp(argv[3], L"light") != 0) return 2; + wchar_t* end = nullptr; + const DWORD hoverArgb = wcstoul(argv[4], &end, 0); + if (!*argv[4] || *end || (hoverArgb >> 24) != 255) return 2; + const DWORD pressedArgb = wcstoul(argv[5], &end, 0); + if (!*argv[5] || *end || (pressedArgb >> 24) != 255) return 2; + const bool interactive = argc > 6; + SetProcessDPIAware(); + WNDCLASSW type = {}; + type.lpfnWndProc = WindowProc; + type.hInstance = GetModuleHandleW(nullptr); + type.lpszClassName = L"HarnessInstallerRenderTest"; + type.hCursor = LoadCursorW(nullptr, IDC_ARROW); + if (!RegisterClassW(&type)) return 3; + HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[6] : L"Installer rendering test", + WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX, 100, 100, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), + nullptr, nullptr, type.hInstance, nullptr); + if (!window || FAILED(InstallerApplyFrame(window))) return 4; + HWND stock = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr); + HWND source = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, stock, nullptr, type.hInstance, nullptr); + SetPropW(window, L"HarnessInstaller.Stage", reinterpret_cast(1)); + SetPropW(window, L"HarnessInstaller.ExtractProgress", reinterpret_cast(50)); + const auto createProgress = [&]() { + return InstallerShowProgress(window, source, dark, static_cast(dpi), hoverArgb, pressedArgb, argv[1], + L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%"); + }; + ShowWindow(window, SW_SHOWNOACTIVATE); + bool clientFrame = true; + for (int x : {8, 300, 580}) { + POINT point{MulDiv(x, dpi, 96), MulDiv(24, dpi, 96)}; + ClientToScreen(window, &point); + clientFrame = SendMessageW(window, WM_NCHITTEST, 0, MAKELPARAM(point.x, point.y)) == HTCLIENT && clientFrame; + } + HWND failed = InstallerShowProgress(window, source, dark, static_cast(dpi), hoverArgb, pressedArgb, L"", + L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%"); + ShowWindow(stock, SW_SHOW); + const bool failurePreservesStock = !failed && IsWindowVisible(stock); + if (failed) DestroyWindow(failed); + // Warm retained font and window-animation resources before checking page-owned GDI objects. + HWND warmup = createProgress(); + if (!warmup) { DestroyWindow(window); return 5; } + UpdateWindow(warmup); + ShowWindow(window, SW_MINIMIZE); + ShowWindow(window, SW_RESTORE); + UpdateWindow(warmup); + DestroyWindow(warmup); + const DWORD initialGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); + HWND progress = createProgress(); + if (!progress) { DestroyWindow(window); return 5; } + UpdateWindow(progress); + if (interactive) { + MSG message; + BOOL result; + while ((result = GetMessageW(&message, nullptr, 0, 0)) > 0) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + return result == 0 ? 0 : 7; + } + RECT originalBounds, restoredBounds; + GetWindowRect(window, &originalBounds); + const auto captionPixel = [&](int x) -> COLORREF { + // Print into a private bitmap: visible-window clipping must not affect pixel assertions. + HDC dc = CreateCompatibleDC(nullptr); + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = MulDiv(600, dpi, 96); + info.bmiHeader.biHeight = -MulDiv(600, dpi, 96); + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + void* pixels = nullptr; + HBITMAP bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0); + if (!dc || !bitmap) { + if (dc) DeleteDC(dc); + if (bitmap) DeleteObject(bitmap); + return CLR_INVALID; + } + HGDIOBJ previous = SelectObject(dc, bitmap); + if (!previous) { DeleteObject(bitmap); DeleteDC(dc); return CLR_INVALID; } + SendMessageW(progress, WM_PRINTCLIENT, reinterpret_cast(dc), PRF_CLIENT); + const COLORREF pixel = GetPixel(dc, MulDiv(x + 8, dpi, 96), MulDiv(24, dpi, 96)); + SelectObject(dc, previous); + DeleteObject(bitmap); + DeleteDC(dc); + return pixel; + }; + const COLORREF normalColor = dark ? RGB(21, 21, 23) : RGB(255, 255, 255); + const auto colorref = [](DWORD argb) { return RGB((argb >> 16) & 255, (argb >> 8) & 255, argb & 255); }; + const COLORREF hoverColor = colorref(hoverArgb); + const COLORREF pressedColor = colorref(pressedArgb); + const auto expectPixel = [&](int x, COLORREF expected) { + const COLORREF actual = captionPixel(x); + if (actual != CLR_INVALID && actual == expected) return true; + std::fprintf(stderr, "caption pixel x=%d dpi=%d expected=0x%08lx actual=0x%08lx\n", x, dpi, expected, actual); + return false; + }; + bool captionStates = true; + ShowWindow(progress, SW_HIDE); + for (int x : {504, 548}) { + const LPARAM point = MAKELPARAM(MulDiv(x + 20, dpi, 96), MulDiv(24, dpi, 96)); + captionStates = expectPixel(x, normalColor) && captionStates; + SendMessageW(progress, WM_MOUSEMOVE, 0, point); + captionStates = expectPixel(x, hoverColor) && captionStates; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, point); + captionStates = expectPixel(x, pressedColor) && captionStates; + const int other = x == 504 ? 548 : 504; + SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(MulDiv(other + 20, dpi, 96), MulDiv(24, dpi, 96))); + captionStates = expectPixel(other, normalColor) && captionStates; + captionStates = expectPixel(x, normalColor) && captionStates; + SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(-1, -1)); + captionStates = expectPixel(x, normalColor) && captionStates; + SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, point); + captionStates = expectPixel(x, pressedColor) && captionStates; + SendMessageW(progress, WM_CANCELMODE, 0, 0); + captionStates = expectPixel(x, hoverColor) && captionStates; + SendMessageW(progress, WM_MOUSELEAVE, 0, 0); + captionStates = expectPixel(x, normalColor) && captionStates; + } + ShowWindow(progress, SW_SHOW); + const LPARAM minimizePoint = MAKELPARAM(MulDiv(524, dpi, 96), MulDiv(24, dpi, 96)); + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + bool buttonRelease = !IsIconic(window) && GetCapture() == progress; + SendMessageW(progress, WM_LBUTTONUP, 0, MAKELPARAM(-1, -1)); + buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + ReleaseCapture(); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + buttonRelease = buttonRelease && !IsIconic(window); + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + SendMessageW(progress, WM_CANCELMODE, 0, 0); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + const bool minimized = IsIconic(window) != FALSE; + ShowWindow(window, SW_RESTORE); + GetWindowRect(window, &restoredBounds); + const bool restored = !IsIconic(window) && EqualRect(&originalBounds, &restoredBounds); + const LPARAM closePoint = MAKELPARAM(MulDiv(568, dpi, 96), MulDiv(24, dpi, 96)); + MSG closeMessage; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint); + bool closeRelease = !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + closeRelease = closeRelease && !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE); + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint); + SendMessageW(progress, WM_LBUTTONUP, 0, closePoint); + closeRelease = closeRelease && PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE); + LARGE_INTEGER frequency, started, finished; + QueryPerformanceFrequency(&frequency); + std::vector samples; + // Full repaint is the exposure/restore path; fixture startup is excluded. + constexpr int frameCount = 300; + for (int frame = 0; frame < frameCount; ++frame) { + InvalidateRect(progress, nullptr, FALSE); + QueryPerformanceCounter(&started); + UpdateWindow(progress); + QueryPerformanceCounter(&finished); + samples.push_back(1000.0 * (finished.QuadPart - started.QuadPart) / frequency.QuadPart); + } + double total = 0; + for (double sample : samples) total += sample; + std::printf("samples_ms=["); + for (size_t index = 0; index < samples.size(); ++index) { + std::printf("%s%.3f", index ? "," : "", samples[index]); + } + std::printf("]\n"); + std::sort(samples.begin(), samples.end()); + std::printf("fixture=standalone-progress dpi=%d dark=%d frames=%d screen=%dx%d foreground=%d mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n", + dpi, dark, frameCount, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), GetForegroundWindow() == window, + total / samples.size(), samples[samples.size() / 2], samples[samples.size() * 95 / 100], samples.back()); + const bool completed = InstallerFinishProgress(progress) && + GetPropW(window, L"HarnessInstaller.CompletedPercent") == reinterpret_cast(100); + WCHAR caption[160] = {}; + GetWindowTextW(progress, caption, ARRAYSIZE(caption)); + const bool finalCaption = wcscmp(caption, L"Extracting 100%") == 0; + DestroyWindow(progress); + const DWORD finalGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); + DestroyWindow(window); + std::printf("client_frame=%d stock_preserved=%d completed=%d final_caption=%d caption_states=%d button_release=%d close_release=%d minimized=%d restored=%d gdi_before=%lu gdi_after=%lu\n", + clientFrame, failurePreservesStock, completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi); + return clientFrame && failurePreservesStock && completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6; +} diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 094047c701..913b418b98 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -466,7 +466,7 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without expect(await composer.innerText()).toBe(draft) expect(await picker.getAttribute('aria-haspopup')).toBe('menu') expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0) - expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis') expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) @@ -492,9 +492,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)! expect(next.id).not.toBe(creator.id) expect(next.session.header.agentPreset).toBe('standard') - expect(scaffold.ctx.sessionProjections.stateOf(next.session, 'agentPreset')).toBe('standard') + expect(scaffold.ctx.sessionProjections.checkpoint(next.session).agentPreset?.val).toBe('standard') expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard') - expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis') await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor() expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools }) expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') diff --git a/apps/web/tests/blank-preset-reuse.e2e.ts b/apps/web/tests/blank-preset-reuse.e2e.ts index 5f9958ab5f..dff731ac19 100644 --- a/apps/web/tests/blank-preset-reuse.e2e.ts +++ b/apps/web/tests/blank-preset-reuse.e2e.ts @@ -69,10 +69,11 @@ describe('web e2e: blank Session reuse under the requested preset', () => { function liveComposition(sessionId: SessionId) { const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) return undefined + const projections = scaffold.ctx.sessionProjections.snapshot(agent.session).values return { mounted: scaffold.ctx.agentPresets.composedPreset(agent.ctx), - projected: scaffold.ctx.sessionProjections.stateOf(agent.session, 'agentPreset'), - plan: scaffold.ctx.sessionProjections.stateOf(agent.session, 'plan'), + projected: projections.agentPreset, + plan: projections.plan, } } diff --git a/apps/web/tests/blank-writer-reuse.e2e.ts b/apps/web/tests/blank-writer-reuse.e2e.ts index 99b2be07e3..74cb108982 100644 --- a/apps/web/tests/blank-writer-reuse.e2e.ts +++ b/apps/web/tests/blank-writer-reuse.e2e.ts @@ -72,7 +72,7 @@ describe.each([false, true])('web e2e: selected blank writer held: %s', (held) = if (!held) { expect(selectedId).toBe(HELD) const reclaimed = scaffold.ctx.agents.get(HELD)! - expect(scaffold.ctx.sessionProjections.stateOf(reclaimed.session, 'plan')).toMatchObject({ active: true }) + expect(scaffold.ctx.sessionProjections.checkpoint(reclaimed.session).plan?.val).toMatchObject({ active: true }) await expect(scaffold.ctx.sessionPersistence.open(HELD, 'write')) .rejects.toMatchObject({ name: 'SessionAlreadyOwnedError' }) } diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index ab98c23ce8..6092eddc02 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -593,7 +593,7 @@ describe('web e2e: long Chat scroll contract', () => { const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url)) await mkdir(artifactRoot, { recursive: true }) const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`)) - const controlledClock = scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold' + const controlledClock = !scenario.reduced await withScrollWorld({ failureShot: `visible-fold-${scenario.name}`, controlledClock, @@ -757,17 +757,19 @@ describe('web e2e: long Chat scroll contract', () => { try { settled = world.scaffold.whenTurnSettled(30_000) if (controlledClock) { - // Advance every animation frame so the race cannot miss the intermediate fold height. + // Sample each animation frame, including when the CI browser misses real-time frames. await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) try { await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => { if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button') button.click() }) - await expect.poll(async () => { - await page.clock.runFor(16) - return page.locator('[data-fold-race-applied]').count() - }, { timeout: 10_000 }).toBe(1) + if (scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold') { + await expect.poll(async () => { + await page.clock.runFor(16) + return page.locator('[data-fold-race-applied]').count() + }, { timeout: 10_000 }).toBe(1) + } await expect.poll(async () => { await page.clock.runFor(16) return page.locator('[data-chat-motion="collapse"]').count() diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts index e85d3ce4e2..7d363a8dc4 100644 --- a/apps/web/tests/collapse-timing.e2e.ts +++ b/apps/web/tests/collapse-timing.e2e.ts @@ -12,7 +12,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, parseSeedFixture, readPersistedEvents, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, openSettings, saveFailureShot, writeComposerDraft } from './support.ts' +import { newEnglishPage, openSettings, pinBrowserClock, pinHostClock, saveFailureShot, WEB_FIXTURE_TIME, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/collapse-timing', import.meta.url)) const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) @@ -38,8 +38,12 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { let fixture: string let prompt: string let replayDir: string + let hostClock: ReturnType | undefined beforeAll(async () => { + // Seeded and live prompt timestamps share the renderer's fixture day, so a run + // that spans Asia/Shanghai midnight renders no date prefix in the aria. + if (MODE !== 'record') hostClock = pinHostClock() fixture = await readFile(SEED, 'utf8') const prompts = fixtureUserPrompts(fixture) expect(prompts).toHaveLength(1) @@ -56,9 +60,10 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { // This rendering borrows a completed turn and replays only its final response as a continuation. compareReplaySession: false, }) - await seedSession(scaffold, fixture, SEED_ID) + await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME }) browser = await chromium.launch({ headless: true }) page = await newEnglishPage(browser) + if (MODE !== 'record') await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() @@ -66,8 +71,12 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { afterAll(async () => { const failures: unknown[] = [] - await browser?.close().catch((error: unknown) => failures.push(error)) - await scaffold?.close().catch((error: unknown) => failures.push(error)) + try { + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + } finally { + hostClock?.mockRestore() + } if (replayDir !== undefined) { await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) } diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 11b0dcce85..0954aa180a 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -195,6 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr expect(calls).toEqual([]) await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() + // The bundle's README is its detail document, shown between its components and its source. + await panel.locator('[data-detail-document] h1').waitFor() await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts index 657c95fbdd..8419b55b79 100644 --- a/apps/web/tests/cot-translation.e2e.ts +++ b/apps/web/tests/cot-translation.e2e.ts @@ -212,6 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor() await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() + // The bundle's README is its detail document, shown between its components and its source. + await panel.locator('[data-detail-document] h1').waitFor() await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts new file mode 100644 index 0000000000..7a35661fe4 --- /dev/null +++ b/apps/web/tests/cua-optin.e2e.ts @@ -0,0 +1,128 @@ +/** Optional CUA uses existing plugin activation and offers a separate persistent PTC action. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser } from 'playwright' +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 { 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) => { + const directory = await mkdtemp(join(tmpdir(), 'cua-optin-web-')) + let ownedScaffold: WebScaffold | undefined + let browser: Browser | undefined + try { + const patch = join(directory, 'native-fixture.yml') + // The executable cannot answer native permission queries, so setup must offer retry in the detail page. + await writeFile(patch, JSON.stringify([{ id: 'cua', disabled: false, config: { helperPath: process.execPath } }])) + // Install the test provider explicitly so the helper override also exercises session wiring on Linux. + const scaffold = ownedScaffold = await launchWebScaffold({ + profile: { packages: [{ dir: fileURLToPath(new URL('../../../packages/experimental/cua-macos', import.meta.url)), enabled: false }] }, + harnessHome: join(directory, 'home'), extraOverlayPath: [ + fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), patch, + ] }) + const initialDefault = selectedDefault ?? 'standard' + if (selectedDefault !== undefined) await scaffold.ctx.settings.update('agent-preset-registry', { selectedDefault }) + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + const originalAgent = scaffold.ctx.agents.list()[0]! + const ptcAgent = (await scaffold.ctx.agents.create({ sessionId: SessionId('cua-existing-ptc'), + setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') }, + })).agent + const runtimeFiber = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')?.fiber + const originalRuntime = scaffold.ctx.ptcRuntime + const originalRuntimeFiber = runtimeFiber() + expect(originalRuntimeFiber).toBeDefined() + expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + const settingsPath = join(scaffold.harnessHome, 'profiles/scaffold/cordis.patch.yml') + const before = await readFile(settingsPath, 'utf8') + const selected = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')).dsh?.profile?.bundles + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') + const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true }) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + expect(await card.textContent()).toContain('请切换到 PTC 模式') + // Bundle transitions await native cleanup under the browser action deadline. + await toggle.click() + await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) + const setup = page.getByRole('dialog', { name: '使用 Computer Use 前需要设置', exact: true }) + await setup.waitFor() + expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) + await setup.getByRole('button', { name: '稍后', exact: true }).click() + expect(selected()).toContain('@deepseek-ai/dsh-cua-bundle') + expect(await readFile(settingsPath, 'utf8')).toBe(before) + expect(runtimeFiber() === originalRuntimeFiber).toBe(true) + expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) + expect(scaffold.ctx.agents.get(originalAgent.id) === originalAgent).toBe(true) + expect(scaffold.ctx.tools.schemas(originalAgent).some(tool => tool.name === 'cua')).toBe(false) + expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(true) + expect((await scaffold.ctx.systemPrompt.assemble({ scope: ptcAgent })).tools.map(tool => tool.name)).toEqual(['run_code']) + const fresh = await scaffold.ctx.agents.create({ sessionId: SessionId('cua-new-ptc'), + setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') }, + }) + expect(scaffold.ctx.tools.schemas(fresh.agent).some(tool => tool.name === 'cua')).toBe(true) + await toggle.click() + await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' }) + expect(scaffold.ctx.get('computerUse')).toBeUndefined() + for (const agent of [ptcAgent, fresh.agent]) expect(scaffold.ctx.tools.schemas(agent).some(tool => tool.name === 'cua')).toBe(false) + expect(runtimeFiber() === originalRuntimeFiber).toBe(true) + expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + expect(await readFile(settingsPath, 'utf8')).toBe(before) + await card.getByRole('button', { name: '查看 Computer Use', exact: true }).click() + const detailToggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: '启用 Computer Use', exact: true }) + await detailToggle.click() + await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) + const panel = page.locator('[data-cua-settings]') + await panel.waitFor() + const defaultSetting = panel.getByRole('group', { name: '新会话默认模式', exact: true }) + await expect.poll(() => defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).isEnabled()).toBe(true) + await defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).click() + await expect.poll(() => scaffold.ctx.agentPresets.defaultId).toBe('ptc') + expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault) + expect(runtimeFiber() === originalRuntimeFiber).toBe(true) + expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + const confirmed = await scaffold.ctx.agents.create({ sessionId: SessionId('cua-confirmed-default'), + setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx) }, + }) + expect(scaffold.ctx.tools.schemas(confirmed.agent).some(tool => tool.name === 'cua')).toBe(true) + const saved = await readFile(settingsPath, 'utf8') + expect(saved).toContain('selectedDefault: ptc') + expect(saved).not.toContain('executionMode:') + const persistent = panel.getByRole('group', { name: 'PTC 执行方式', exact: true }) + await persistent.getByRole('button', { name: '设为持久化', exact: true }).click() + await persistent.getByRole('button', { name: '已设为持久化', exact: true }).waitFor() + expect(scaffold.ctx.ptcRuntime.programFormat).toBe('repl') + expect(scaffold.ctx.ptcRuntime === originalRuntime).toBe(false) + const persistentRuntimeFiber = runtimeFiber() + const runtime = scaffold.ctx.ptcRuntime, owner = {} + const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1' + expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(1) + const configured = await readFile(settingsPath, 'utf8') + expect(configured).toContain('executionMode: persistent-in-memory') + expect(scaffold.ctx.agentPresets.defaultId).toBe('ptc') + expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault) + await detailToggle.click() + await detailToggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' }) + expect(await readFile(settingsPath, 'utf8')).toBe(configured) + expect(runtimeFiber() === persistentRuntimeFiber).toBe(true) + expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(2) + await detailToggle.click() + await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) + expect(await readFile(settingsPath, 'utf8')).toBe(configured) + expect(runtimeFiber() === persistentRuntimeFiber).toBe(true) + expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(3) + await runtime.release(owner) + } finally { + try { await browser?.close() } finally { + try { await ownedScaffold?.close() } finally { await rm(directory, { recursive: true, force: true }) } + } + } +}) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts new file mode 100644 index 0000000000..18072ae8b1 --- /dev/null +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -0,0 +1,150 @@ +/** Built permission UI talks to the real provider; the isolated helper substitutes only macOS privilege observations. */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +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 { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, 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)) +const expected = fileURLToPath(new URL('./expected/cua-permissions/missing.expected.md', import.meta.url)) + +// System-permission onboarding belongs to the macOS-only bundle; rendering and policy units remain portable. +it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fresh observation and persists application revocation', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cua-permissions-web-')) + const resources: { scaffold?: WebScaffold; browser?: Browser; closeFixture?: () => Promise } = {} + onTestFinished(async () => { + try { await resources.browser?.close() } finally { + try { await resources.scaffold?.close() } finally { + try { await resources.closeFixture?.() } finally { await rm(root, { recursive: true, force: true }) } + } + } + }) + const helper = await installNativeFixture(root) + resources.closeFixture = await startFixtureHost(helper) + const state = join(root, 'permissions.json'), audit = join(root, 'helper-events.jsonl') + const launchApplication = { name: 'Visual Studio Code.app', path: '/Applications/Visual Studio Code.app', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' } + await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication })) + await writeFile(join(root, 'observe-helper'), '') + const fixtureBundle = join(root, 'fixture-bundle') + await mkdir(fixtureBundle) + await writeFile(join(fixtureBundle, 'package.json'), JSON.stringify({ name: 'cua-permission-fixture', version: '1.0.0', + dsh: { bundle: { patch: 'cordis.patch.yml' } } })) + await writeFile(join(fixtureBundle, 'cordis.patch.yml'), `- id: cua\n config: ${JSON.stringify({ helperPath: helper })}\n`) + const scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: bundle, enabled: true }, { dir: fixtureBundle, enabled: true }] }, + }) + resources.scaffold = scaffold + await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], + value: [{ id: 'a'.repeat(64), name: 'Fixture', executablePath: '/Applications/Fixture.app' }] }]) + const browser = await chromium.launch() + resources.browser = browser + const page = await newEnglishPage(browser), tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-cua-permissions')) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0]! + await scaffold.ctx.agentPresets.select(agent, 'ptc') + scaffold.ctx.approval.setPolicy(agent, 'never') + await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], value: [] }]) + // Application consent remains independent of the session's tool-approval policy. + const grant = scaffold.ctx.tools.execute({ agent, signal: new AbortController().signal, + 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]') + 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 grant).isError).toBe(false) + await approval.waitFor({ state: 'hidden' }) + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') + expect(await card.getByRole('button', { name: /Accessibility permission|Screen Recording permission/ }).count()).toBe(0) + await page.setViewportSize({ width: 1680, height: 600 }) + const plugins = page.locator('[data-plugin-panel]') + const openDetails = page.getByRole('button', { name: 'View Computer Use', exact: true }) + await openDetails.scrollIntoViewIfNeeded() + expect(await plugins.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + await openDetails.click() + const panel = page.locator('[data-cua-settings]') + const accessibility = panel.getByRole('group', { name: 'Accessibility', exact: true }) + await accessibility.getByRole('button', { name: 'Request permission', exact: true }).waitFor() + expect(await panel.getByRole('region', { name: 'Authorize Computer Use', exact: true }).getByRole('group').count()).toBe(4) + expect(await panel.getByRole('region', { name: 'Manage applications Computer Use can access', exact: true }).count()).toBe(1) + await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) + expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0) + expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1) + expect(await accessibility.textContent()).toContain('In System Settings, allow') + expect(await accessibility.textContent()).toContain('to read interface controls, click, and type.') + expect(await panel.getByRole('group', { name: 'Screen Recording', exact: true }).getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1) + await expect.poll(() => accessibility.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) + await expect.poll(() => panel.getAttribute('aria-busy')).toBe('false') + await compareOrRefreshGolden(expected, await captureStableAria(page, '[data-cua-settings]', scaffold.workspaceCwd), webSnapshotMode()) + try { + for (const width of [1680, 390]) { + await page.setViewportSize({ width, height: 1000 }) + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + const measure = () => panel.evaluate(element => ({ + clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, + overflowing: [...element.querySelectorAll('*')].filter(child => + child.getBoundingClientRect().right > element.getBoundingClientRect().right + 1, + ).map(child => ({ tag: child.tagName, className: child.className, text: child.textContent?.slice(0, 80) })), + })) + await expect.poll(async () => { + const layout = await measure() + return layout.scrollWidth <= layout.clientWidth ? true : { width, colorScheme, ...layout } + }).toBe(true) + if (webSnapshotMode() === 'refresh') { + const directory = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) + await mkdir(directory, { recursive: true }) + const path = join(directory, `cua-permission-design-${process.pid}-${width}-${colorScheme}.png`) + await panel.screenshot({ path, animations: 'disabled' }) + console.log(`CUA permission design: ${path}`) + } + } + } + } finally { + await page.emulateMedia({ colorScheme: null }) + await page.setViewportSize({ width: 1680, height: 1000 }) + } + await accessibility.getByRole('button', { name: 'Request permission', exact: true }).click() + await expect.poll(() => panel.getAttribute('aria-busy')).toBe('false') + expect(await accessibility.getByRole('button', { name: 'Request permission', exact: true }).count()).toBe(1) + await writeFile(state, JSON.stringify({ accessibility: true, screenRecording: false, launchApplication })) + // Headless pages retain document focus; model returning from System Settings at the window-event boundary. + await page.evaluate(() => { window.dispatchEvent(new Event('focus')) }) + await accessibility.getByText('Allowed', { exact: true }).waitFor() + const screen = panel.getByRole('group', { name: 'Screen Recording', exact: true }) + expect(await accessibility.getByRole('button').count()).toBe(1) + await accessibility.getByRole('button', { name: 'Allowed', exact: true }).click() + expect(await screen.getByRole('button').count()).toBe(1) + await expect.poll(() => panel.getAttribute('aria-busy')).toBe('false') + await panel.getByRole('button', { name: 'Revoke', exact: true }).click() + await panel.getByRole('heading', { name: 'Always-allowed applications', exact: true }).waitFor({ state: 'hidden' }) + await panel.getByRole('heading', { name: 'Applications not allowed', exact: true }).waitFor() + await panel.getByRole('button', { name: 'Allow asking again', exact: true }).click() + await panel.getByText('Fixture', { exact: true }).waitFor({ state: 'hidden' }) + const saved = await readFile(join(scaffold.harnessHome, 'profiles/scaffold/cordis.patch.yml'), 'utf8') + expect(saved).toContain('allowedApps: []') + const events = (await readFile(audit, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { event: string; method: string; params: object }) + .filter(record => record.event === 'call').map(({ method, params }) => ({ method, params })) + expect(events).toContainEqual({ method: 'requestPermission', params: { permission: 'accessibility' } }) + expect(events.filter(event => event.method === 'openPermissionSettings')).toEqual([ + { method: 'openPermissionSettings', params: { permission: 'accessibility' } }, + ]) + expect(events.every(event => ['permissionStatus', 'requestPermission', 'openPermissionSettings', 'applicationIcons', 'getApp'].includes(event.method))).toBe(true) + expect(tripwire.pageErrors).toEqual([]) +}, 60_000) diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index 4a5c9b38ad..3e4ab96099 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -6,6 +6,65 @@ - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 +- region "详细介绍": + - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1] + - paragraph: English | 中文 + - heading "Summary" [level=2] + - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input. + - heading "Table of Contents" [level=2] + - list: + - listitem: Use this package + - listitem: Understand the implementation + - listitem: Further Exploration + - listitem: Model Experience + - listitem: Known Limitations and Deferred Work + - listitem: Dev Note + - separator + - paragraph + - heading "Use this package" [level=2] + - paragraph: + - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language. + - code: auto + - text: follows the interface language; an explicit language code such as + - code: zh + - text: "," + - code: en + - text: ", or" + - code: ja + - text: overrides it. Save applies the preferences. + - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts. + - paragraph: + - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native + - code: deepseek-flash + - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically. + - separator + - paragraph + - heading "Understand the implementation" [level=2] + - paragraph: + - text: The bundle patch inserts + - code: translator + - text: and + - code: cot-translation + - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory." + - separator + - paragraph + - heading "Further Exploration" [level=2] + - paragraph: Text translation subsystem · Translator service · GUI consumer + - separator + - paragraph + - heading "Model Experience" [level=2] + - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context. + - heading "KV Cache effect" [level=4] + - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer. + - heading "Known Limitations and Deferred Work" [level=2] + - paragraph + - list: + - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible. + - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations. + - separator + - paragraph + - heading "Dev Note" [level=3] + - paragraph: None. - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求 - text: 翻译服务 已覆盖 diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index 29f85e915b..b2c7e3c3d7 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -6,6 +6,65 @@ - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 +- region "详细介绍": + - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1] + - paragraph: English | 中文 + - heading "Summary" [level=2] + - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input. + - heading "Table of Contents" [level=2] + - list: + - listitem: Use this package + - listitem: Understand the implementation + - listitem: Further Exploration + - listitem: Model Experience + - listitem: Known Limitations and Deferred Work + - listitem: Dev Note + - separator + - paragraph + - heading "Use this package" [level=2] + - paragraph: + - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language. + - code: auto + - text: follows the interface language; an explicit language code such as + - code: zh + - text: "," + - code: en + - text: ", or" + - code: ja + - text: overrides it. Save applies the preferences. + - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts. + - paragraph: + - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native + - code: deepseek-flash + - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically. + - separator + - paragraph + - heading "Understand the implementation" [level=2] + - paragraph: + - text: The bundle patch inserts + - code: translator + - text: and + - code: cot-translation + - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory." + - separator + - paragraph + - heading "Further Exploration" [level=2] + - paragraph: Text translation subsystem · Translator service · GUI consumer + - separator + - paragraph + - heading "Model Experience" [level=2] + - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context. + - heading "KV Cache effect" [level=4] + - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer. + - heading "Known Limitations and Deferred Work" [level=2] + - paragraph + - list: + - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible. + - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations. + - separator + - paragraph + - heading "Dev Note" [level=3] + - paragraph: None. - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求 - text: 翻译服务 diff --git a/apps/web/tests/expected/cua-permissions/missing.expected.md b/apps/web/tests/expected/cua-permissions/missing.expected.md new file mode 100644 index 0000000000..4255b31e9f --- /dev/null +++ b/apps/web/tests/expected/cua-permissions/missing.expected.md @@ -0,0 +1,26 @@ +- region "Authorize Computer Use": + - heading "Authorize Computer Use" [level=3] + - group "Default mode for new sessions": + - strong: Default mode for new sessions + - paragraph: Computer Use is available only in PTC mode. + - button "Set to PTC" + - group "PTC execution mode": + - strong: PTC execution mode + - paragraph: Keep variables and application objects across tool calls. Switching restarts the PTC runtime. + - button "Use persistent mode" + - group "Accessibility": + - strong: Accessibility + - paragraph: In System Settings, allow Visual Studio Code.app to read interface controls, click, and type. + - button "Request permission" + - group "Screen Recording": + - 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. +- region "Manage applications Computer Use can access": + - heading "Manage applications Computer Use can access" [level=3] + - heading "Always-allowed applications" [level=4] + - list: + - listitem: + - strong: Fixture + - button "Revoke" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index e81a95fdf1..de6a45d25c 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -42,11 +42,11 @@ - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - - /placeholder: 256K + - /placeholder: 1M - text: 272K - text: 最大输出 token 数 - textbox "最大输出 token 数 1": - - /placeholder: 32K + - /placeholder: 128K - text: 128K - group "输入类型 1": - text: 输入类型 diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 8210c7907d..73e8fc9a47 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -49,10 +49,10 @@ - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - - /placeholder: 256K + - /placeholder: 1M - text: 最大输出 token 数 - textbox "最大输出 token 数 1": - - /placeholder: 32K + - /placeholder: 128K - group "输入类型 1": - text: 输入类型 - checkbox "文本" [checked] diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index bbeacdc7be..e0b4eb62cf 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -49,10 +49,10 @@ - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - - /placeholder: 256K + - /placeholder: 1M - text: 最大输出 token 数 - textbox "最大输出 token 数 1": - - /placeholder: 32K + - /placeholder: 128K - group "输入类型 1": - text: 输入类型 - checkbox "文本" diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 37d3a232b4..27c2e72887 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -38,6 +38,10 @@ - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - switch "启用 开发者工具" + - listitem: + - button "查看 Computer Use": Computer Use + - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 + - switch "启用 Computer Use" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index eab9dd48e1..cfe553e2bb 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -38,6 +38,10 @@ - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - switch "启用 开发者工具" + - listitem: + - button "查看 Computer Use": Computer Use + - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 + - switch "启用 Computer Use" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index f3dd4a7bc6..d58ad62b03 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -38,6 +38,10 @@ - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - switch "启用 开发者工具" + - listitem: + - button "查看 Computer Use": Computer Use + - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 + - switch "启用 Computer Use" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/preview.expected.md b/apps/web/tests/expected/plugin-manager/preview.expected.md index 4f78082f46..b72a046686 100644 --- a/apps/web/tests/expected/plugin-manager/preview.expected.md +++ b/apps/web/tests/expected/plugin-manager/preview.expected.md @@ -6,6 +6,53 @@ - code: "@deepseek-ai/dsh-experimental-badge-skill-bundle" - paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。 - img "DSH 徽章技能功能预览" +- region "详细介绍": + - heading "@deepseek-ai/dsh-experimental-badge-skill-bundle" [level=1] + - paragraph: English | 中文 + - heading "Summary" [level=2] + - paragraph: Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + - heading "Table of Contents" [level=2] + - list: + - listitem: Use this package + - listitem: Understand the implementation + - listitem: Further Exploration + - listitem: Model Experience + - listitem: Known Limitations and Deferred Work + - listitem: Dev Note + - separator + - paragraph + - heading "Use this package" [level=2] + - paragraph: + - text: In Web or Desktop, open Plugins and enable + - strong: DSH badge skill + - text: in Official. Switch it off to remove its profile layer. + - paragraph: The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged. + - separator + - paragraph + - heading "Understand the implementation" [level=2] + - paragraph: + - code: cordis.patch.yml + - text: contributes the + - code: skill-badge + - text: provider row on the Host. Later root patches can disable that row; an override alone does not select the bundle. The profile composer owns patch ordering and errors. + - separator + - paragraph + - heading "Further Exploration" [level=2] + - paragraph: Capability implementation + - separator + - paragraph + - heading "Model Experience" [level=2] + - paragraph: Indirectly, through the capability implementation, which owns model context and results. + - heading "KV Cache effect" [level=4] + - paragraph: The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + - heading "Known Limitations and Deferred Work" [level=2] + - paragraph + - list: + - listitem: A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle. + - separator + - paragraph + - heading "Dev Note" [level=3] + - paragraph: None. - heading "包含的组件" [level=4] - text: 共 1 个 · 1 已停用 - list: diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 75dda78845..85d666fd3e 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/expected/session-archive-active/dialog.expected.md b/apps/web/tests/expected/session-archive-active/dialog.expected.md index 2a6f4d638f..4604467eeb 100644 --- a/apps/web/tests/expected/session-archive-active/dialog.expected.md +++ b/apps/web/tests/expected/session-archive-active/dialog.expected.md @@ -3,7 +3,7 @@ - button "Close" - paragraph: “ARCHIVE_ACTIVE run the long command” still has work in progress. Archiving stops it first; you can restore the session later from the “All conversations (show archived)” filter in the sidebar, and the stopped work will not resume on its own. - list "Work that will be stopped": - - listitem: The turn in progress - listitem: "1 background job: node -e \"const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, 200); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, 60000)\"" + - listitem: The turn in progress - button "Cancel" - button "Stop and archive" diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 0abc01dcd1..9b5da0d115 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -308,6 +308,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await custom.getByRole('button', { name: '添加模型', exact: true }).click() await custom.getByLabel('模型 ID 1').fill('acme-large') await custom.getByRole('button', { name: '模型选项 1' }).click() + expect(await custom.getByLabel('上下文窗口 1').getAttribute('placeholder')).toBe('1M') + expect(await custom.getByLabel('最大输出 token 数 1').getAttribute('placeholder')).toBe('128K') expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() // The switch seeds off, low, medium, and high rows; this route serves no medium. @@ -322,6 +324,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('acme-gateway:') expect(document).toContain('reasoningEfforts:') await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({ + context: { contextWindow: 1_000_000 }, inputModalities: ['text', 'image'], reasoning: { efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] }, }) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index fd8217a2d8..e59c1239ba 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -87,6 +87,13 @@ describe('web e2e: plan review takeover round trip', () => { // waitFor is race-free. const card = page.locator('[data-plan-review-key]') await card.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) + const firstHeader = sessionEvents.find(event => event.type === 'request/header') + const firstPrompt = sessionEvents.find(event => event.type === 'system/message') + expect(firstHeader?.data.header.tools?.map(tool => tool.name)).toContain('exit_plan_mode') + expect(firstPrompt?.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('\n')) + .toContain('You are in plan mode.') + const clock = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'time-context') + expect(clock?.seq).toBeLessThan(firstHeader!.seq) // The plan-review request must NOT land on the generic question flow. expect(await page.locator('[data-question-key]').count()).toBe(0) await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 05d3bbecda..2b1392cc39 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -709,6 +709,8 @@ describe('web e2e: plugin manager', () => { expect(fits).toBe(true) await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor() await detail.getByText(badgeVersion, { exact: true }).waitFor() + // The bundle's README is its detail document, shown between its components and its source. + await detail.locator('[data-detail-document] h1').waitFor() await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[badgeVersion, '{{version}}']], diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0e32a5a7d6..157d7b97c6 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -681,15 +681,28 @@ async function toggleOptionalBundles(page: Page): Promise { } }) expect(result.optional.map(entry => entry.name).sort()).toEqual([...OPTIONAL_BUNDLES].sort()) - expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation' || entry.error !== null)).toEqual([]) + expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation')).toEqual([]) + expect(result.optional.filter(entry => entry.error !== null)).toEqual([]) // Metadata resolves through the Worker's Node internal modules: every optional bundle shows its title and icon. expect(result.optional.filter(entry => entry.metaError !== null || entry.title !== 'object' || entry.icon !== 'string')).toEqual([]) expect(result.inventoryErrors).toEqual([]) console.info(`preview boot: optional bundle toggles ${JSON.stringify(result.toggles.map(({ name, enable, disable, error }) => ({ name, enable, disable, error })))}`) - expect(result.toggles.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied') + const cua = result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')! + expect(cua).toMatchObject({ + enable: 'applied', disable: 'applied', + error: null, + restored: true, + }) + expect(cua.changed).toBeGreaterThan(0) + expect(cua.inactive).toEqual([]) + expect(cua.plugins).toContainEqual(expect.objectContaining({ + moduleName: '@deepseek-ai/dsh-experimental-cua-macos', enabled: false, fiberPhase: null, + })) + const supported = result.toggles.filter(toggle => toggle.name !== '@deepseek-ai/dsh-cua-bundle') + expect(supported.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied') .map(({ name, enable, disable }) => ({ name, enable, disable }))).toEqual([]) // Each bundle changes Loader rows, every enabled row it changes runs, and switching it off restores the prior rows. - expect(result.toggles.filter(toggle => toggle.changed === 0 || toggle.inactive.length > 0 || !toggle.restored) + expect(supported.filter(toggle => toggle.changed === 0 || toggle.inactive.length > 0 || !toggle.restored) .map(({ name, changed, inactive, restored }) => ({ name, changed, inactive, restored }))).toEqual([]) const pluginsWith = (bundle: string): Array<{ moduleName: string; enabled: boolean; fiberPhase: string | null }> => result.toggles.find(toggle => toggle.name === bundle)?.plugins ?? [] diff --git a/apps/web/tests/session-archive-active.e2e.ts b/apps/web/tests/session-archive-active.e2e.ts index d192d3d207..8360f937d8 100644 --- a/apps/web/tests/session-archive-active.e2e.ts +++ b/apps/web/tests/session-archive-active.e2e.ts @@ -162,7 +162,7 @@ describe.skipIf(MODE === 'record')('web e2e: archiving a running Session stops i // A foreground Bash command is a registered job from its start, so the // Host reports it beside the turn; stopping the turn kills it. await expect.poll(() => dialog.getByRole('list', { name: 'Work that will be stopped' }).locator('li').allTextContents()) - .toEqual(['The turn in progress', `1 background job: ${WAIT_COMMAND}`]) + .toEqual([`1 background job: ${WAIT_COMMAND}`, 'The turn in progress']) // Asking is not archiving: the Host set is untouched, the turn and its // Bash child keep running, and the row stays in the sidebar. expect([...scaffold.ctx.workspaceRegistry.archivedSessionIds]).toEqual([]) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 7a35bcfcb9..a1b1284a19 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -43,8 +43,8 @@ async function remote( } /** POST one generated Session Remote unary through the API Gateway carrier. */ -function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { - return remote(scaffold, `session/${method}`, { request }) +async function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { + return await remote(scaffold, `session/${method}`, { request }) } /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 562eae2efd..df35c5e0e0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -82,6 +82,8 @@ "tests/plugin-config.e2e.ts", "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", + "tests/cua-optin.e2e.ts", + "tests/cua-permissions.e2e.ts", "tests/schedule-header.e2e.ts", "tests/worktree-opt-in.e2e.ts", "tests/session-inspector.e2e.ts", diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml index 751cf9d842..d9b991241c 100644 --- a/benchmarks/agent-continuation/README.i18n.yaml +++ b/benchmarks/agent-continuation/README.i18n.yaml @@ -12,8 +12,8 @@ en: 66a769ce60d49ff3 zh: 7e0bfa7c598157fa /backend-continuation-benchmarks/run: - en: 900cdb7cdb11cf21 - zh: 0dafdd9a3be1b04e + en: a9a943044795029c + zh: 45c8257f51ec658a /backend-continuation-benchmarks/measurements: en: 84301730c56f0614 zh: 6e90a8d6574fa896 diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md index 3074ba73e0..f7c1415a87 100644 --- a/benchmarks/agent-continuation/README.md +++ b/benchmarks/agent-continuation/README.md @@ -18,7 +18,7 @@ Measure long-history request processing, cold tool-heavy continuation, and repea From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks. -The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([archived evidence](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically. +The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([archived evidence](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)), and checkpoint-enabled SDK-minimal continuation uses a 5,500 ms hosted expectation with 1.25× headroom rounded up to a whole second (7,000 ms). This hosted expectation does not use the 2× reference-machine scale. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically. diff --git a/benchmarks/agent-continuation/README.zh.md b/benchmarks/agent-continuation/README.zh.md index 6adc4143db..530e5089b2 100644 --- a/benchmarks/agent-continuation/README.zh.md +++ b/benchmarks/agent-continuation/README.zh.md @@ -18,7 +18,7 @@ 在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。 -测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([已归档的证据](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。 +测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([已归档的证据](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)),启用 checkpoint 的 SDK-minimal 续聊使用 5,500 ms 托管期望值,乘 1.25× 余量后向上取整到整秒(7,000 ms)。该托管期望值不使用参考机器的 2× 比例。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。 diff --git a/benchmarks/agent-continuation/agent-continuation.bench.ts b/benchmarks/agent-continuation/agent-continuation.bench.ts index 12958ac151..c2a95f28a6 100644 --- a/benchmarks/agent-continuation/agent-continuation.bench.ts +++ b/benchmarks/agent-continuation/agent-continuation.bench.ts @@ -14,8 +14,12 @@ import { WORKLOAD } from './workload.ts' const ATTEMPTS = 5 const WORKER_TIMEOUT_MS = 60_000 -/** M4 Pro / Node 24.19 baseline expectations, before shared CI scaling and variance headroom. */ -const EXPECTED_MS = { 'profile-continuation': 1_700 } as const +/** Checkpoint-enabled two-CPU hosted runs have an upper observed median of 5,432.567 ms. */ +const EXPECTED_PROFILE_CONTINUATION_CI_MS = 5_500 +/** Apply variance headroom and round up to a whole second; the baseline is already a CI measurement. */ +const PROFILE_CONTINUATION_BUDGET_MS = Math.ceil( + EXPECTED_PROFILE_CONTINUATION_CI_MS * PERFORMANCE_BUDGET_HEADROOM / 1_000, +) * 1_000 /** Standard two-CPU hosted CI baseline request-history median is 582.304 ms. */ const EXPECTED_BASELINE_REQUEST_CI_MS = 600 const BASELINE_REQUEST_BUDGET_MS = Math.ceil(EXPECTED_BASELINE_REQUEST_CI_MS * PERFORMANCE_BUDGET_HEADROOM) @@ -54,7 +58,7 @@ const CASES: Readonly> = { }, } -type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | keyof typeof EXPECTED_MS +type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | 'profile-continuation' type Report = ContinuationReport | CatalogReport | ProfileReport function workerName(scenario: Scenario): string { @@ -81,6 +85,24 @@ function expectTotalWithinBudget(value: number, budget: number): void { expect(value).toBeLessThanOrEqual(budget) } +describe('checkpoint-enabled SDK-minimal hosted calibration', () => { + it('accepts the recorded checkpoint-enabled workload above the buffered-write budget', () => { + const recordedMedian = median([4_182.691, 5_946.326875, 5_581.127338, 5_432.567004, 4_362.941759]) + + expect(recordedMedian).toBe(5_432.567004) + expect(() => expectTotalWithinBudget(recordedMedian, 4_250)).toThrow() + expectTotalWithinBudget(recordedMedian, PROFILE_CONTINUATION_BUDGET_MS) + expect(PROFILE_CONTINUATION_BUDGET_MS).toBe(7_000) + }) + + it('rejects a material SDK-minimal profile slowdown', () => { + const regressionMedian = median([7_600, 7_900, 8_000, 7_700, 7_800]) + + expect(regressionMedian).toBe(7_800) + expect(() => expectTotalWithinBudget(regressionMedian, PROFILE_CONTINUATION_BUDGET_MS)).toThrow() + }) +}) + describe('standard hosted catalog calibration', () => { it('accepts the recorded two-CPU samples that exceed the historical budget', () => { const recordedMedian = median([797.373945, 883.157358, 858.363927, 790.568538, 904.5785669999999]) @@ -187,7 +209,7 @@ describe('continuing tool-heavy Sessions with large histories', () => { const totalMs = samples.map(sample => sample.totalMs) const budgetMs = scenario === 'request-history' ? REQUEST_HISTORY_BUDGET_MS : scenario === 'catalog' ? CATALOG_BUDGET_MS - : scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : ciTimeBudget(EXPECTED_MS[scenario]) + : scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : PROFILE_CONTINUATION_BUDGET_MS const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM console.log(JSON.stringify({ benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD, diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 5e95356c12..29c2ef81ba 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -8,7 +8,7 @@ import { scheduler, setTimeout as delay } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import type SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -21,6 +21,7 @@ import { z } from 'zod' import { generationLogPath, sessionDir, toHeaderLine } from '../../packages/session/session-persistence-jsonl/src/format.ts' import { compressZstdFrame } from '../../packages/session/session-persistence-jsonl/src/zstd.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' +import { createBenchmarkSessionController } from './session-controller.ts' const KEY = 'syntheticProjectionList' const CWD = '/synthetic-projection-benchmark' @@ -53,7 +54,6 @@ const viewSchema = z.object({ sessionOrdinal: z.number().int(), rows: z.number() type ProjectionState = z.infer declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { syntheticProjectionList: ProjectionState } interface SessionProjectionMap { syntheticProjectionList: z.infer } } @@ -170,9 +170,9 @@ async function seed(root: string, workload: typeof WORKLOADS[keyof typeof WORKLO async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[keyof typeof WORKLOADS], observation: ViewObservation, listWorkSliceMs?: number): Promise { await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register({ + await ctx.plugin(AgentRegistry) + await ctx.sessionProjections.register({ key: KEY, stateVersion: 1, stateSchema, init: header => makeState(Number(header.id.slice('projection-bench-'.length)), workload.baseRows), apply: state => state, @@ -206,7 +206,7 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) new RemoteAccessService(ctx) - return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false }) + return createBenchmarkSessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }) } async function memory(): Promise { diff --git a/benchmarks/session-corpus/session-controller.ts b/benchmarks/session-corpus/session-controller.ts new file mode 100644 index 0000000000..f5b3cac97e --- /dev/null +++ b/benchmarks/session-corpus/session-controller.ts @@ -0,0 +1,32 @@ +/** Session-controller initialization for synthetic corpus workers without attachment I/O. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import SessionController, { type Config } from '@deepseek-ai/dsh-api-session-controller' + +class BenchmarkSessionController extends SessionController { + initialize(): Promise { + return this[Service.init]() + } +} + +/** + * Initialize the controller against the worker's partial Host services. + * @param ctx - worker context owning service disposal. + * @param config - native-opener and list-scheduling settings. + * @returns controller ready for list, search, and fork calls. + */ +export async function createBenchmarkSessionController(ctx: Context, config: Config): Promise { + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'], + }, + } as never) + const controller = new BenchmarkSessionController(ctx, config, { canOpenPath: () => false }) + await controller.initialize() + return controller +} diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index 2d1acd56ba..ea51bdfaa3 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -9,7 +9,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import type SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -28,6 +28,7 @@ import TokenMeter from '@deepseek-ai/dsh-token-meter' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { serializeRecord } from '../../packages/storage/storage-json/src/format.ts' import { ANCHOR_COUNT } from './corpus-shape.ts' +import { createBenchmarkSessionController } from './session-controller.ts' import { anchorHeader, corpusSessionId, @@ -141,7 +142,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 200, writeIntervalMs: 5_000 }) // Content search is opt-in; enabled deployments defer the in-memory index to the first search. await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search' }) - ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }) await ctx.plugin(SessionStatsPlugin) await ctx.plugin(SessionTurnOutlinePlugin) @@ -158,7 +159,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) new RemoteAccessService(ctx) - const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) + const controller = await createBenchmarkSessionController(ctx, { nativeOpen: false }) return { ctx, controller } } diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index 2b6d644960..00d5f17d33 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' -import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { @@ -12,7 +12,7 @@ import SessionStore, { SessionLogOffset, SessionPreparation, } from '@deepseek-ai/dsh-session' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type AgentHandle } from '@deepseek-ai/dsh-agent' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' @@ -30,7 +30,7 @@ import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline import TokenMeter from '@deepseek-ai/dsh-token-meter' // These Host-only adapters have no public Node export and are compiled into the benchmark worker. import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts' -import { installModelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts' +import { modelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts' @@ -128,11 +128,10 @@ function memoryDelta( } } -async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean): Promise { +async function installProjectionSet(ctx: Context): Promise { // Mirrors projection owners mounted by the base and web-app bundles without timing profile boot. - if (!agentLoopOwnsBoundary) ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - installModelSelectionProjection(ctx) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.sessionProjections.register(modelSelectionProjection) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, @@ -163,9 +162,10 @@ class SessionBenchmarkHost { if (agentScenario) await mountAgentLoopTestDependencies(ctx) else { await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) } - await installProjectionSet(ctx, agentScenario) + await installProjectionSet(ctx) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) let history: SessionHistoryController | undefined if (scenario === 'first-history') { diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts index 354f9b12bb..52e10ac919 100644 --- a/benchmarks/user-text/user-text.worker.client.ts +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -82,7 +82,7 @@ const FACTS: Readonly> = { sessionLabels: ['会话一'], const KIB = 1024 /** Exercise each workload's code paths before measuring sub-millisecond baseline evaluations. */ const WARMUP_PAIRS = 10 -/** Batch every single-document workload to limit noise in its shortest evaluation intervals. */ +/** Base batch size; half-sized scaling inputs use twice as many evaluations. */ const SINGLE_DOCUMENT_EVALUATIONS = 20 const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. ' const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n' @@ -160,22 +160,32 @@ interface Workload { readonly id: string readonly group: UserTextWorkloadGroup readonly texts: readonly string[] + /** Scaling pairs process the same total keys or characters per sample, including their GC cost. */ + readonly evaluationsPerSample: number } /** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */ function workloads(): readonly Workload[] { return [ - { id: 'typical-messages', group: 'unmatched', texts: messages(5_000) }, + { id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }, ...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL }) - .map(([name, unit]): Workload => ({ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)] })), + .map(([name, unit]): Workload => ({ + id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS, + })), ...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({ - id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), + id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), evaluationsPerSample: 1, })), - { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)] }, - { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)] }, + { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS }, + { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS }, ...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [ - { id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)] }, - { id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)] }, + { + id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)], + evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS, + }, + { + id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)], + evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS, + }, ]), ] } @@ -364,8 +374,7 @@ async function allocations(): Promise { async function timings(attempts: number): Promise { const results: UserTextWorkloadTiming[] = [] - for (const { id, group, texts } of workloads()) { - const evaluationsPerSample = texts.length === 1 ? SINGLE_DOCUMENT_EVALUATIONS : 1 + for (const { id, group, texts, evaluationsPerSample } of workloads()) { const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample) results.push({ id, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8ba6244399..adcb970d6d 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -27,8 +27,8 @@ en: e6402b28c0ed15fa zh: 6c07be9047b42056 /deepseek-harness-architecture/session-log: - en: a2e35c43438fec6d - zh: e27bdac8e9feff32 + en: f1729d805c20df36 + zh: 6b71b6039663e2e7 /deepseek-harness-architecture/capability-seams: en: a5bc20c3f2a1b370 zh: 91773a281410b2f4 diff --git a/docs/architecture.md b/docs/architecture.md index 2aea688db0..ede41eb827 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,7 +125,7 @@ Session consumers know only the current logical format. Header-only `stat` and ` **Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations. -**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Domain services await registration during activation, retain private handles, and expose business methods for other plugins. Releasing registration removes global participation; retained handles continue folding Session events. `AgentRegistry` owns turn and step state and exposes `turnBoundaryOf(session)` ([registration lifecycle](../packages/session/session-projection/README.md)). [Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 98258507d1..9ac9b293d2 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -129,7 +129,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` **模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。 -**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。领域服务在激活时等待注册完成,在内部保留句柄,并向其他插件提供业务方法。释放注册会移除全局参与关系;已有句柄仍可继续折叠 Session 事件。`AgentRegistry` 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`([注册生命周期](../packages/session/session-projection/README.zh.md))。 [工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index b4525b76f2..b35db923e6 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: b000cf0e2f33b2f7 - zh: 50600b606a37cdaf + en: 3e87315977a89b5a + zh: 626a75f3bebaa39f diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 20972ef086..7c66c89b46 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -52,6 +52,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_cua["cua"] + svc_cuaAccess["ctx.cuaAccess
Computer Use permission controls"] pkg_office_to_pdf["office-to-pdf"] svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -337,6 +339,7 @@ flowchart LR pkg_cordis_host_runner --> svc_dynamicCordisRunner pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials + pkg_cua --> svc_cuaAccess pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions @@ -488,6 +491,7 @@ flowchart LR svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai + svc_cuaAccess --> pkg_cua svc_deepseekAccount --> pkg_api_account_controller svc_deepseekAccount --> pkg_llm_deepseek svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -632,6 +636,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. | +| `ctx.cuaAccess` | `core` | [`cua`](../packages/experimental/cua) | - | [`cua`](../packages/experimental/cua) | - | The native provider owns application consent and cancellation; the optional Client page queries and changes those decisions through its Remote. | | `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 38168695d9..8e649183bc 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -54,6 +54,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_cua["cua"] + svc_cuaAccess["ctx.cuaAccess
Computer Use permission controls"] pkg_office_to_pdf["office-to-pdf"] svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -339,6 +341,7 @@ flowchart LR pkg_cordis_host_runner --> svc_dynamicCordisRunner pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials + pkg_cua --> svc_cuaAccess pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions @@ -490,6 +493,7 @@ flowchart LR svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai + svc_cuaAccess --> pkg_cua svc_deepseekAccount --> pkg_api_account_controller svc_deepseekAccount --> pkg_llm_deepseek svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -634,6 +638,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 | +| `ctx.cuaAccess` | `core` | [`cua`](../packages/experimental/cua) | - | [`cua`](../packages/experimental/cua) | - | 原生 provider 负责应用授权和取消操作;可选的 Client 权限页面通过 Remote 查询和修改授权。 | | `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 52d5739e7b..bc34e4f61f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -59,7 +59,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-instructions` -- `inject`: `sessionProjections` · `workingDirectory` +- `inject`: `agents` · `workingDirectory` - `source`: [`packages/context/agent-instructions/src/config.ts:18`](../packages/context/agent-instructions/src/config.ts) ```ts config-catalog @@ -96,7 +96,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -141,7 +141,7 @@ export type Config = PresetDefinition ## `@deepseek-ai/dsh-agent-preset-registry` -- `inject`: `loader` · `sessionProjections` +- `inject`: `loader` · `sessionProjections` · `agents` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts) @@ -226,7 +226,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -960,7 +960,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `agents` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1472,7 +1472,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-goal` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts) +- `source`: [`packages/goal/goal/src/index.ts:168`](../packages/goal/goal/src/index.ts) ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -1537,8 +1537,8 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:50`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1576,8 +1576,8 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-codex/src/index.ts:49`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -1888,13 +1888,13 @@ export interface PiAiProviderProfile { compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor - * the installed catalog sizes (default 262,144). A guess by construction, so + * the installed catalog sizes (default 1,000,000). A guess by construction, so * a deployment whose gateway serves smaller models corrects it here. */ defaultContextWindow?: number /** * Output capability for a model this route lists that neither the entry nor - * the installed catalog sizes (default 32,768). This sizes the model; it + * the installed catalog sizes (default 128,000). This sizes the model; it * never becomes a per-request cap on its own. */ defaultMaxTokens?: number @@ -2396,7 +2396,7 @@ export interface Config { - `inject`: `shell` · `approval` · `sessions` · `sessionProjections` - `refs`: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) · `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts) +- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts) ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -2464,7 +2464,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` -- `inject`: `tools` · `systemPrompt` · `sessionProjections` +- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents` - `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog @@ -2482,7 +2482,8 @@ export interface PlanModeConfig { ## `@deepseek-ai/dsh-plugin-manager` - `inject`: `loader` · `profileContext` -- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.md) +- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -2507,6 +2508,18 @@ export interface Config { * unless that is npm's own registry or one of these. */ fallbackRegistries?: string[] + /** How many of the newest versions above the installed one a version listing reads and returns. */ + versionListLimit?: number + /** How many single-version registry lookups a version listing runs at once. */ + versionLookupConcurrency?: number + /** Largest bundle detail document, and largest locale file read for one, in bytes. */ + detailMaxBytes?: number + /** Largest image served beside a bundle detail document, in bytes. */ + detailImageMaxBytes?: number + /** Deadline of one npm file CDN request for an offered bundle's detail document. */ + detailFetchTimeoutMs?: number + /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources?: NpmFileSource[] } ``` @@ -2517,8 +2530,8 @@ export interface Config { ## `@deepseek-ai/dsh-plugin-market` - `inject`: `pluginManager` -- `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.md) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts) ```ts config-catalog /** Index sources and fetch limits. */ @@ -2529,8 +2542,24 @@ export interface Config { fetchTimeoutMs: number /** Largest accepted index body in bytes. */ maxBytes: number - /** Lifetime of a successfully read index document. */ + /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */ cacheTtlMs: number + /** Largest detail document in bytes. */ + detailMaxBytes: number + /** Largest image beside a detail document in bytes. */ + detailImageMaxBytes: number + /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */ + detailRepositoryMaxBytes: number + /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */ + detailCacheMaxBytes: number + /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */ + detailCacheDir?: string + /** Deadline of one Git command of the clone fallback. */ + detailGitTimeoutMs: number + /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources: NpmFileSource[] + /** Hosts serving the GitLab repository files API. */ + detailGitlabHosts: string[] } ``` @@ -3056,7 +3085,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -- `inject`: `sessions` · `sessionProjections` +- `inject`: `sessions` · `sessionProjections` · `agents` - `source`: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) ```ts config-catalog @@ -3391,7 +3420,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ## `@deepseek-ai/dsh-subagent` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) @@ -3643,7 +3672,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory` +- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory` - `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ```ts config-catalog @@ -3704,7 +3733,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3892,7 +3921,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` -- `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` +- `inject`: `agents` · `goals` · `tools` · `systemPrompt` - `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog @@ -3967,8 +3996,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-present` -- `inject`: `tools` · `fs` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/deliverables/tool-present/src/index.ts:16`](../packages/deliverables/tool-present/src/index.ts) +- `inject`: `tools` · `fs` · `agents` · `workingDirectory` +- `source`: [`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts) ```ts config-catalog /** Per-call delivery limit. */ @@ -4060,7 +4089,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` -- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` +- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `agents` - `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog @@ -4619,7 +4648,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:56`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:51`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ @@ -4666,7 +4695,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `package` | `inject` | `source` | | --- | --- | --- | | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | -| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/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) | @@ -4734,6 +4763,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-computer-use` | — | [`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts) | | `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) | | `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) | +| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | @@ -4821,7 +4851,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-client-ui-slots` | — | [`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts) | | `@deepseek-ai/dsh-client-web` | — | [`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts) | | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | -| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | +| `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | | `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | @@ -4847,6 +4877,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | +| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | | `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) | | `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) | | `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ae0e1b0b49..7a4c2a2fc3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -61,7 +61,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-instructions` -- `inject`: `sessionProjections` · `workingDirectory` +- `inject`: `agents` · `workingDirectory` - `source`: [`packages/context/agent-instructions/src/config.ts:18`](../packages/context/agent-instructions/src/config.ts) ```ts config-catalog @@ -98,7 +98,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -143,7 +143,7 @@ export type Config = PresetDefinition ## `@deepseek-ai/dsh-agent-preset-registry` -- `inject`: `loader` · `sessionProjections` +- `inject`: `loader` · `sessionProjections` · `agents` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts) @@ -228,7 +228,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -962,7 +962,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `agents` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1474,7 +1474,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-goal` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts) +- `source`: [`packages/goal/goal/src/index.ts:168`](../packages/goal/goal/src/index.ts) ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -1539,8 +1539,8 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:50`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1578,8 +1578,8 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-codex/src/index.ts:49`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -1890,13 +1890,13 @@ export interface PiAiProviderProfile { compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor - * the installed catalog sizes (default 262,144). A guess by construction, so + * the installed catalog sizes (default 1,000,000). A guess by construction, so * a deployment whose gateway serves smaller models corrects it here. */ defaultContextWindow?: number /** * Output capability for a model this route lists that neither the entry nor - * the installed catalog sizes (default 32,768). This sizes the model; it + * the installed catalog sizes (default 128,000). This sizes the model; it * never becomes a per-request cap on its own. */ defaultMaxTokens?: number @@ -2398,7 +2398,7 @@ export interface Config { - `inject`: `shell` · `approval` · `sessions` · `sessionProjections` - `refs`: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) · `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts) +- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts) ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -2466,7 +2466,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` -- `inject`: `tools` · `systemPrompt` · `sessionProjections` +- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents` - `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog @@ -2484,7 +2484,8 @@ export interface PlanModeConfig { ## `@deepseek-ai/dsh-plugin-manager` - `inject`: `loader` · `profileContext` -- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.zh.md) +- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -2509,6 +2510,18 @@ export interface Config { * unless that is npm's own registry or one of these. */ fallbackRegistries?: string[] + /** How many of the newest versions above the installed one a version listing reads and returns. */ + versionListLimit?: number + /** How many single-version registry lookups a version listing runs at once. */ + versionLookupConcurrency?: number + /** Largest bundle detail document, and largest locale file read for one, in bytes. */ + detailMaxBytes?: number + /** Largest image served beside a bundle detail document, in bytes. */ + detailImageMaxBytes?: number + /** Deadline of one npm file CDN request for an offered bundle's detail document. */ + detailFetchTimeoutMs?: number + /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources?: NpmFileSource[] } ``` @@ -2519,8 +2532,8 @@ export interface Config { ## `@deepseek-ai/dsh-plugin-market` - `inject`: `pluginManager` -- `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.zh.md) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts) ```ts config-catalog /** Index sources and fetch limits. */ @@ -2531,8 +2544,24 @@ export interface Config { fetchTimeoutMs: number /** Largest accepted index body in bytes. */ maxBytes: number - /** Lifetime of a successfully read index document. */ + /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */ cacheTtlMs: number + /** Largest detail document in bytes. */ + detailMaxBytes: number + /** Largest image beside a detail document in bytes. */ + detailImageMaxBytes: number + /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */ + detailRepositoryMaxBytes: number + /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */ + detailCacheMaxBytes: number + /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */ + detailCacheDir?: string + /** Deadline of one Git command of the clone fallback. */ + detailGitTimeoutMs: number + /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources: NpmFileSource[] + /** Hosts serving the GitLab repository files API. */ + detailGitlabHosts: string[] } ``` @@ -3058,7 +3087,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -- `inject`: `sessions` · `sessionProjections` +- `inject`: `sessions` · `sessionProjections` · `agents` - `source`: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) ```ts config-catalog @@ -3393,7 +3422,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ## `@deepseek-ai/dsh-subagent` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) @@ -3645,7 +3674,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory` +- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory` - `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ```ts config-catalog @@ -3706,7 +3735,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3894,7 +3923,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` -- `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` +- `inject`: `agents` · `goals` · `tools` · `systemPrompt` - `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog @@ -3969,8 +3998,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-present` -- `inject`: `tools` · `fs` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/deliverables/tool-present/src/index.ts:16`](../packages/deliverables/tool-present/src/index.ts) +- `inject`: `tools` · `fs` · `agents` · `workingDirectory` +- `source`: [`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts) ```ts config-catalog /** Per-call delivery limit. */ @@ -4062,7 +4091,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` -- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` +- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `agents` - `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog @@ -4621,7 +4650,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:56`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:51`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ @@ -4668,7 +4697,7 @@ export interface Config { | `package` | `inject` | `source` | | --- | --- | --- | | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | -| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/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) | @@ -4736,6 +4765,7 @@ export interface Config { | `@deepseek-ai/dsh-computer-use` | — | [`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts) | | `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) | | `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) | +| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | @@ -4823,7 +4853,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-slots` | — | [`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts) | | `@deepseek-ai/dsh-client-web` | — | [`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts) | | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | -| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | +| `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | | `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | @@ -4849,6 +4879,7 @@ export interface Config { | `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | +| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | | `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) | | `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) | | `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) | diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 013ecb4344..3776cb0096 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -21,8 +21,8 @@ en: 0894bd9f35430753 zh: 2d28090e71134edd /cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata: - en: 2382017c7f84ace6 - zh: 03af99592baaab9f + en: 68b463cd3d2e5fb0 + zh: 80ea797c5770c573 /cookbook-adding-a-workspace-package/6-verify: en: 80c7fb90b1175aae zh: 2ba37962dbc57299 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index af3547ea1f..11cca9a60d 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -14,7 +14,7 @@ packages/// # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) - locale/en.json # optional display metadata: meta.title and meta.description + locale/en.json # optional display metadata: meta.title, meta.description, and meta.detail locale/zh.json # translations using the same fields README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form @@ -147,6 +147,8 @@ Export `/locale/en.json` for locale lookup; expose ` For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork. +A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; include the file and its images in `files`. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file. + Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. Only Settings shortens literal package-name and module-name fallbacks by removing npm scope and Cordis/DSH prefixes; Plugin Manager keeps complete names. Locale titles and descriptions remain unchanged. A row configuration page can use its registered summary when the plugin has no display description. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index c45632d3e7..b22976fb9d 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -14,7 +14,7 @@ packages/// # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) - locale/en.json # optional display metadata: meta.title and meta.description + locale/en.json # optional display metadata: meta.title, meta.description, and meta.detail locale/zh.json # translations using the same fields README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form @@ -147,6 +147,8 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex 要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。 +组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;将该文件及其图片加入 `files`。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。 + 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。 diff --git a/docs/defensive-patterns/conversation-and-session.md b/docs/defensive-patterns/conversation-and-session.md index 7b91d6a676..f2f610ebe4 100644 --- a/docs/defensive-patterns/conversation-and-session.md +++ b/docs/defensive-patterns/conversation-and-session.md @@ -24,7 +24,7 @@ function PlanChip({ useProjection }: PlanChipProps) { } // The computation is a registered unit; the registry drives it and publishes changes. -const planDefinition: ProjectionDefinition<'plan'> = { +const planDefinition: ProjectionDefinition<'plan', typeof emptyPlan> = { key: 'plan', stateVersion: 1, init: () => emptyPlan, diff --git a/docs/defensive-patterns/conversation-and-session.zh.md b/docs/defensive-patterns/conversation-and-session.zh.md index 855fdd876f..448ce061ec 100644 --- a/docs/defensive-patterns/conversation-and-session.zh.md +++ b/docs/defensive-patterns/conversation-and-session.zh.md @@ -24,7 +24,7 @@ function PlanChip({ useProjection }: PlanChipProps) { } // The computation is a registered unit; the registry drives it and publishes changes. -const planDefinition: ProjectionDefinition<'plan'> = { +const planDefinition: ProjectionDefinition<'plan', typeof emptyPlan> = { key: 'plan', stateVersion: 1, init: () => emptyPlan, diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md index 7f2d0646c9..5bfda3bd19 100644 --- a/docs/defensive-patterns/remote-and-api.md +++ b/docs/defensive-patterns/remote-and-api.md @@ -93,16 +93,18 @@ The owning package decides and registers its unit, and the controller only reads ```ts ignore-check // packages/workspace//src/index.ts: the rule lives with the feature it serves. -ctx.sessionProjections.register({ +export const durablePageProjectionDefinition = { key: 'durablePage', stateSchema: durablePageState, init: () => ({ durable: false }), apply: applyDurablePage, stateVersion: 1, -}) +} +await ctx.sessionProjections.register(durablePageProjectionDefinition) // packages/api/session-controller: generic orchestration reads the projected value. -const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +const durablePage = await ctx.sessionProjections.register(durablePageProjectionDefinition) +const { durable } = durablePage.stateOf(session) ``` The wrong version decides the rule inside the shared layer: diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md index c621109913..46e1925ef9 100644 --- a/docs/defensive-patterns/remote-and-api.zh.md +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -93,16 +93,18 @@ type SessionRemote = { list(): ...; rename(id: string): ... } // hand-written ```ts ignore-check // packages/workspace//src/index.ts: the rule lives with the feature it serves. -ctx.sessionProjections.register({ +export const durablePageProjectionDefinition = { key: 'durablePage', stateSchema: durablePageState, init: () => ({ durable: false }), apply: applyDurablePage, stateVersion: 1, -}) +} +await ctx.sessionProjections.register(durablePageProjectionDefinition) // packages/api/session-controller: generic orchestration reads the projected value. -const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +const durablePage = await ctx.sessionProjections.register(durablePageProjectionDefinition) +const { durable } = durablePage.stateOf(session) ``` 反例把这条规则的决定放进共享层: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 55ce4542c3..99d814ce08 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,10 +9,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | 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:249`](../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:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../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` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | @@ -23,11 +23,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:640`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:647`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `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` | | `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`) | - | @@ -50,17 +50,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | +| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `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:269`](../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:275`](../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:282`](../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:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `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-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` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 141810510a..ea4ee02415 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,10 +11,10 @@ | 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:249`](../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:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../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` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | @@ -25,11 +25,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:640`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:647`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `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` | | `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`) | - | @@ -52,17 +52,18 @@ | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | +| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `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:269`](../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:275`](../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:282`](../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:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `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-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` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/module-graph.md b/docs/module-graph.md index a51b1fae83..30610b57ba 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -19,6 +19,7 @@ flowchart TD pkg_native_command["native-command"] pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] + pkg_plugin_detail["plugin-detail"] pkg_plugin_market_index["plugin-market-index"] pkg_timeout["timeout"] pkg_util_code_language["util-code-language"] @@ -248,6 +249,7 @@ flowchart TD end subgraph group_experimental["packages/experimental"] pkg_cua["cua"] + pkg_cua_bundle["cua-bundle"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] @@ -658,6 +660,7 @@ flowchart TD pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_time_context --> pkg_session_projection + pkg_time_context --> pkg_util_time pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection @@ -762,6 +765,9 @@ flowchart TD pkg_deepseek_account_platform --> pkg_credentials pkg_deepseek_account_platform --> pkg_deepseek_account pkg_deepseek_account_platform --> pkg_host_webserver + pkg_cua --> pkg_attachment + pkg_cua --> pkg_scope + pkg_cua --> pkg_user_questions pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime pkg_experimental_ptc_runtime_python --> pkg_sandbox pkg_experimental_ptc_runtime_python --> pkg_sandbox_policy @@ -812,7 +818,6 @@ flowchart TD pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session - pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_terminal_bash --> pkg_working_directory @@ -829,7 +834,6 @@ flowchart TD pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session - pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment @@ -889,7 +893,6 @@ flowchart TD pkg_agent_instructions --> pkg_home_paths pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session - pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_agent_instructions --> pkg_working_directory pkg_file_reference_local --> pkg_agent @@ -901,7 +904,6 @@ flowchart TD pkg_tool_present --> pkg_fs pkg_tool_present --> pkg_llm pkg_tool_present --> pkg_session - pkg_tool_present --> pkg_session_projection pkg_tool_present --> pkg_tools pkg_tool_present --> pkg_working_directory pkg_workspace_changes --> pkg_agent @@ -909,14 +911,6 @@ flowchart TD pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools - pkg_cua --> pkg_agent - pkg_cua --> pkg_attachment - pkg_cua --> pkg_computer_use - pkg_cua --> pkg_llm - pkg_cua --> pkg_subprocess - pkg_cua --> pkg_system_prompt - pkg_cua --> pkg_tools - pkg_cua --> pkg_user_questions pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -946,7 +940,6 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session - pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_hooks_codex --> pkg_working_directory pkg_session_title_all_prompts_llm --> pkg_llm @@ -1222,7 +1215,6 @@ flowchart TD pkg_experimental_claude_code_mods --> pkg_llm pkg_experimental_claude_code_mods --> pkg_scope pkg_experimental_claude_code_mods --> pkg_session - pkg_experimental_claude_code_mods --> pkg_session_projection pkg_experimental_claude_code_mods --> pkg_storage_domain pkg_experimental_claude_code_mods --> pkg_subprocess pkg_experimental_claude_code_mods --> pkg_token_meter @@ -1233,7 +1225,6 @@ flowchart TD pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session - pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -1314,6 +1305,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_plugin_market --> pkg_plugin_manager pkg_plugin_market --> pkg_settings + pkg_plugin_market --> pkg_subprocess pkg_plugin_market --> pkg_tools pkg_plugin_market --> pkg_typert_protocol pkg_headless --> pkg_agent @@ -1362,7 +1354,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_hook_protocol pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_hooks_claude_code --> pkg_working_directory @@ -1401,6 +1392,7 @@ flowchart TD pkg_schedule --> pkg_subagent pkg_schedule --> pkg_tools pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_util_time pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1459,6 +1451,7 @@ flowchart TD | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | +| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — | | [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`util-code-language`](../packages/util/code-language) | `util` | — | @@ -1545,6 +1538,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | @@ -1660,7 +1654,7 @@ flowchart TD | [`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) | | [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`util-time`](../packages/util/time) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`util-values`](../packages/util/values) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1683,6 +1677,7 @@ flowchart TD | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | +| [`cua`](../packages/experimental/cua) | `experimental` | [`attachment`](../packages/attachment/attachment), [`scope`](../packages/core/scope), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`experimental-worktree`](../packages/experimental/worktree) | `experimental` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`working-directory`](../packages/session/working-directory) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1692,10 +1687,10 @@ flowchart TD | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1706,18 +1701,17 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | -| [`cua`](../packages/experimental/cua) | `experimental` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`computer-use`](../packages/computer-use/computer-use), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | -| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-terminal`](../packages/experimental/tool-terminal) | `experimental` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | @@ -1763,8 +1757,8 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | -| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | +| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1775,19 +1769,19 @@ flowchart TD | [`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) | -| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`experimental-subagent-oar`](../packages/experimental/subagent-oar) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index f3fb58ed94..f190ca7079 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -21,6 +21,7 @@ flowchart TD pkg_native_command["native-command"] pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] + pkg_plugin_detail["plugin-detail"] pkg_plugin_market_index["plugin-market-index"] pkg_timeout["timeout"] pkg_util_code_language["util-code-language"] @@ -250,6 +251,7 @@ flowchart TD end subgraph group_experimental["packages/experimental"] pkg_cua["cua"] + pkg_cua_bundle["cua-bundle"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] @@ -660,6 +662,7 @@ flowchart TD pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_time_context --> pkg_session_projection + pkg_time_context --> pkg_util_time pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection @@ -764,6 +767,9 @@ flowchart TD pkg_deepseek_account_platform --> pkg_credentials pkg_deepseek_account_platform --> pkg_deepseek_account pkg_deepseek_account_platform --> pkg_host_webserver + pkg_cua --> pkg_attachment + pkg_cua --> pkg_scope + pkg_cua --> pkg_user_questions pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime pkg_experimental_ptc_runtime_python --> pkg_sandbox pkg_experimental_ptc_runtime_python --> pkg_sandbox_policy @@ -814,7 +820,6 @@ flowchart TD pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session - pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_terminal_bash --> pkg_working_directory @@ -831,7 +836,6 @@ flowchart TD pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session - pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment @@ -891,7 +895,6 @@ flowchart TD pkg_agent_instructions --> pkg_home_paths pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session - pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_agent_instructions --> pkg_working_directory pkg_file_reference_local --> pkg_agent @@ -903,7 +906,6 @@ flowchart TD pkg_tool_present --> pkg_fs pkg_tool_present --> pkg_llm pkg_tool_present --> pkg_session - pkg_tool_present --> pkg_session_projection pkg_tool_present --> pkg_tools pkg_tool_present --> pkg_working_directory pkg_workspace_changes --> pkg_agent @@ -911,14 +913,6 @@ flowchart TD pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools - pkg_cua --> pkg_agent - pkg_cua --> pkg_attachment - pkg_cua --> pkg_computer_use - pkg_cua --> pkg_llm - pkg_cua --> pkg_subprocess - pkg_cua --> pkg_system_prompt - pkg_cua --> pkg_tools - pkg_cua --> pkg_user_questions pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -948,7 +942,6 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session - pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_hooks_codex --> pkg_working_directory pkg_session_title_all_prompts_llm --> pkg_llm @@ -1224,7 +1217,6 @@ flowchart TD pkg_experimental_claude_code_mods --> pkg_llm pkg_experimental_claude_code_mods --> pkg_scope pkg_experimental_claude_code_mods --> pkg_session - pkg_experimental_claude_code_mods --> pkg_session_projection pkg_experimental_claude_code_mods --> pkg_storage_domain pkg_experimental_claude_code_mods --> pkg_subprocess pkg_experimental_claude_code_mods --> pkg_token_meter @@ -1235,7 +1227,6 @@ flowchart TD pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session - pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -1316,6 +1307,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_plugin_market --> pkg_plugin_manager pkg_plugin_market --> pkg_settings + pkg_plugin_market --> pkg_subprocess pkg_plugin_market --> pkg_tools pkg_plugin_market --> pkg_typert_protocol pkg_headless --> pkg_agent @@ -1364,7 +1356,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_hook_protocol pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_hooks_claude_code --> pkg_working_directory @@ -1403,6 +1394,7 @@ flowchart TD pkg_schedule --> pkg_subagent pkg_schedule --> pkg_tools pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_util_time pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1461,6 +1453,7 @@ flowchart TD | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | +| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — | | [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`util-code-language`](../packages/util/code-language) | `util` | — | @@ -1547,6 +1540,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | @@ -1662,7 +1656,7 @@ flowchart TD | [`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) | | [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`util-time`](../packages/util/time) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`util-values`](../packages/util/values) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1685,6 +1679,7 @@ flowchart TD | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | +| [`cua`](../packages/experimental/cua) | `experimental` | [`attachment`](../packages/attachment/attachment), [`scope`](../packages/core/scope), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`experimental-worktree`](../packages/experimental/worktree) | `experimental` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`working-directory`](../packages/session/working-directory) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1694,10 +1689,10 @@ flowchart TD | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1708,18 +1703,17 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | -| [`cua`](../packages/experimental/cua) | `experimental` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`computer-use`](../packages/computer-use/computer-use), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | -| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-terminal`](../packages/experimental/tool-terminal) | `experimental` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | @@ -1765,8 +1759,8 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | -| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | +| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1777,19 +1771,19 @@ flowchart TD | [`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) | -| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`experimental-subagent-oar`](../packages/experimental/subagent-oar) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 533dee6276..7aae4e7d05 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -21,8 +21,8 @@ en: 9188862058c6986b zh: 9188862058c6986b /session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only: - en: bc3be962bc8738b1 - zh: e538aa8ff144468f + en: 8309b4babad90744 + zh: b344ac640d79947e /session-persistence-event-catalog/events/agent-preset: en: 49891ca9be5ac123 zh: 49891ca9be5ac123 @@ -141,14 +141,14 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: 5332aa18683a642f - zh: 025aa9bd6c4e022f + en: 06589ec99fb2bb40 + zh: 3b78f0418d44609b /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af /session-persistence-event-catalog/events/permission/permission-preset-log-only: - en: 41de5f6e1dad8620 - zh: 92cb5d5e87a23405 + en: 54423cb464a0a495 + zh: 8b3a9595255184f0 /session-persistence-event-catalog/events/plan: en: 4294f78d26ed985b zh: 4294f78d26ed985b @@ -1146,8 +1146,8 @@ en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: f75d48e8c151b06d - zh: c0f0dc5f69465285 + en: bf5990688bae50ac + zh: 9212bca7aa7d31e0 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1347,8 +1347,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 7fe97b3da041a921 - zh: eca1f199e605d440 + en: 324d7ebffee10aad + zh: 31f7b8a18571665a /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1524,8 +1524,8 @@ en: 8a9199b1784c70f5 zh: 76c98bc74686fda0 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: e77d5d524dd8b4ea - zh: 4dac28dda78a510f + en: 452ff7ab39ffb3a9 + zh: 33af56489f0a45bc /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: ec10cd8e9c202a81 zh: e08f859bcb13f0d4 @@ -1833,8 +1833,8 @@ en: 809a23c25ded8497 zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 99d932bb6feda454 - zh: aec175795de9b853 + en: db30e659a1cc4c06 + zh: 8585ea4c8178ab37 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1887,8 +1887,8 @@ en: 00f1d435f82d2753 zh: c2ab108ad2748a6c /session-persistence-event-catalog/resolved-persistence-types/preset: - en: f60ecbd156c6f063 - zh: c9149679bbacff21 + en: 26cf71c850765c59 + zh: 7a436be430e04898 /session-persistence-event-catalog/resolved-persistence-types/reason-turn: en: a4ba4d0ee1396381 zh: b6f595f8a696d512 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 9feae13632..056aa2e5f9 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -185,7 +185,7 @@ Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -700,7 +700,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:45`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:37`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -718,7 +718,7 @@ Source: [`packages/api/session-controller/src/types.ts:45`](../packages/api/sess 'permission/preset': { preset: string } ``` -Source: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -4337,7 +4337,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:106`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:98`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5583,7 +5583,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:420`](../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: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) `string` @@ -6377,7 +6377,7 @@ Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +Sources: [`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -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:429`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7790,7 +7790,7 @@ Sources: [`packages/interaction/user-approval/src/index.ts:40`](../packages/inte SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` -Sources: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +Sources: [`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 8764478b01..71fb808356 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -187,7 +187,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -702,7 +702,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:45`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:37`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -720,7 +720,7 @@ export type SessionEvent = { 'permission/preset': { preset: string } ``` -来源:[`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -4339,7 +4339,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:106`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:98`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:420`](../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: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) `string` @@ -6379,7 +6379,7 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:429`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7792,7 +7792,7 @@ SHA-256: `11f70b595aa76897fc4e25a352bfa718c659f44b799e5aa14e84e5a38fe72912` SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` -来源:[`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index eeec1d2d22..01eaa1a4b6 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23574,7 +23574,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:420", + "packages/api/session-controller/src/types.ts:412", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:21", @@ -24096,7 +24096,7 @@ "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:429" + "packages/api/session-controller/src/types.ts:421" ] }, { @@ -24518,7 +24518,7 @@ "digest": "8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad", "names": [], "sources": [ - "packages/interaction/permission-presets/src/index.ts:59" + "packages/interaction/permission-presets/src/index.ts:52" ] }, { @@ -25916,7 +25916,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:106" + "packages/api/session-controller/src/types.ts:98" ] }, { @@ -26079,7 +26079,7 @@ "digest": "fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42", "names": [], "sources": [ - "packages/core/agent/src/types.ts:96" + "packages/core/agent/src/types.ts:84" ] }, { diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 79070a420d..0d52b7d7e5 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,11 +6,11 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: a1412da0219c2a09 - zh: b6869fd548f4c693 + en: 2def418f04e9fc10 + zh: af0bd1610bdc2f2b /profile-management/market-records: - en: a28421bc6481b0ed - zh: 73142d6163a20f50 + en: 3dfc62d837d61b9b + zh: ec223be315f3dc62 /profile-management/profile-reconciliation: en: 42a093314af774d4 zh: 7c1784a3af02b31c diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 606ad59bcb..530af09e92 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -10,10 +10,12 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. +`BundleInfo.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, update eligibility (`updatable`), optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. `InstallBundleOptions.saveExact` passes `--save-exact` to the shared package installer. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. +`BundleDetail` is a profile bundle's long description: `{ status: 'found' }` with its `origin` (`locale` or `readme`), its `path` inside the package, and its `markdown`, or `{ status: 'none' }`; [`readBundleDetail`](../../packages/boot/plugin-manager/README.md#bundle-detail-documents) documents the order and the image route. `NpmFileSource` names a public npm file CDN, `jsdelivr`, `unpkg`, or `npmmirror`; a read's `npmFileSources` lists the ones to ask first. + `PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first. `ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `bundle` and `version` name the package a finished installation added and its manifest version; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. @@ -26,6 +28,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `MarketQuery` selects one page of an index: fuzzy `text`, a `category`, `labels` that must all be present, a `MarketSort`, `offset`, `limit` up to 100, `refresh` to fetch again instead of using a cached document, and `fetchedAt` to continue the pages of one document. `MarketSearchResult` carries the index URL, `title`, optional `description`, market `metadata`, the document's `fetchedAt`, the page's `entries`, the `total` number of matches, and the `nextOffset`. Each `MarketListingEntry` adds `defaultVersion`, the version installed by default, and `installation`, the profile's status for the package name; each of its `versions` adds `install`, the exact spec and optional first registry for `installBundle`, and `compatibility`, the evaluation of its `engines.dsh` including a version exemption. +`PluginDetail` is a market entry's long description: `{ status: 'found' }` with its `origin` (`locale`, `readme`, or `repository`), its `markdown`, and a `document` id whose images `GET /api/plugin-market/detail-file` serves, or `{ status: 'none' }`. The [plugin market documents the order](../../packages/boot/plugin-market/README.md#plugin-detail-documents) its sources are read in. + ## Profile reconciliation @@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** + * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its + * README. An installed bundle is read from its directory without network access; an official bundle the installation + * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached. + * @param name - the bundle's package name, as `listBundles()` lists it. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one + * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked. + * @returns the document with its origin and path, or `none`. + * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or + * `plugin-manager/detail-unavailable`. + */ +@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise + /** Read exact plugin-version exemptions saved in this profile. * @returns Accepted package-name@version keys with the runtime versions they may run on, and any * record or file problem the reader rejected, which the caller reports instead of failing. @@ -191,6 +209,30 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote async inspect(spec: string, options?: InspectOptions, signal?: AbortSignal): Promise +/** Read the published versions newer than an installed registry package's version, from the first registry that answers. + * Official bundles follow the DSH version and are not listed. + * @param name Installed profile dependency name. + * @param options The registry asked first; absent, the one the package was installed from in this process, else the configured one. + * @param signal Ends the lookups early. + * @returns At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration, + * and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked. + */ +@Remote async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise + +/** + * Replace an installed registry bundle with a newer published version. The target is checked on the registry first: + * it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed + * version is removed, so its plugins stop, and the target is installed with the same selection; when that + * installation fails or is cancelled, the removed version is installed again from the registry it came from. + * Without HMR the target is installed over the running version and the result reports the restart. + * @param name Installed profile dependency name. + * @param version Exact published version newer than the installed one. + * @param options The registry asked first, as for `listVersions`, and the request id naming the progress and the + * target installation's logs and cancellation. + * @returns The versions, the last step that ran with its result, and the reinstallation when one ran. + */ +@Remote async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise + /** Persist a plugin entry's desired enablement and apply it on live profiles. * @param id Loader entry identity returned by listPlugins. * @param enabled Whether the plugin should run. @@ -288,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with * or `plugin-market/invalid-index`. */ async entry(url: string, name: string): Promise + +/** + * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file + * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names + * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm + * version without one, of its `package.json` `repository`, at the default branch. An npm version from another + * registry has no readable package files, so only the entry's `repository` is read. + * @param url - a configured index URL. + * @param name - the plugin's package name. + * @param version - one listed version. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names + * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not + * configured are not asked. + * @returns the document with its origin and image document id, or `none`. + * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, + * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`. + */ +@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise ``` Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts) @@ -433,6 +494,24 @@ An installation moved between its Host phases. `installing` is announced once pe Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + +#### `plugin-manager/update-state` — emit + +An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed. Announced once per step. + +```ts cordis-catalog +/** + * An update moved to its next step: checking the target, removing the installed version, installing the target, + * or reinstalling the removed version after the target failed. Announced once per step. + * @mode emit + * @param progress - the update's request id and step. + */ +'plugin-manager/update-state'(progress: PluginUpdateProgress): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + ### `plugin-market/*` events diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index c7f830fbb9..1cab38289f 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -10,10 +10,12 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新资格(`updatable`)、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 `InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 +`BundleDetail` 是 profile 中组合包的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale` 或 `readme`)、在包内的 `path` 和 `markdown`;否则为 `{ status: 'none' }`。[`readBundleDetail`](../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 说明读取顺序和图片路由。`NpmFileSource` 指定一个公共 npm 文件 CDN:`jsdelivr`、`unpkg` 或 `npmmirror`;一次读取的 `npmFileSources` 列出先请求的 CDN。 + `PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。 `ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 @@ -26,6 +28,8 @@ `MarketQuery` 选择索引的一页:模糊匹配的 `text`、一个 `category`、必须全部具备的 `labels`、一个 `MarketSort`、`offset`、不超过 100 的 `limit`、表示重新获取而不使用缓存文档的 `refresh`,以及用于继续读取同一文档后续页的 `fetchedAt`。`MarketSearchResult` 携带索引 URL、`title`、可选的 `description`、市场 `metadata`、该文档的 `fetchedAt`、本页的 `entries`、匹配总数 `total` 和 `nextOffset`。每个 `MarketListingEntry` 额外携带 `defaultVersion`,即默认安装的版本,以及 `installation`,即该包名在当前 profile 中的状态;其 `versions` 中的每个版本额外携带 `install`,即交给 `installBundle` 的精确 spec 和可选的首选 registry,以及 `compatibility`,即包含版本豁免在内的 `engines.dsh` 评估结果。 +`PluginDetail` 是市场条目的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale`、`readme` 或 `repository`)、`markdown`,以及一个 `document` id,该文档中的图片由 `GET /api/plugin-market/detail-file` 提供;否则为 `{ status: 'none' }`。读取其来源的顺序见[插件市场的说明](../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents)。 + ## Profile 重新组合 @@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** + * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its + * README. An installed bundle is read from its directory without network access; an official bundle the installation + * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached. + * @param name - the bundle's package name, as `listBundles()` lists it. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one + * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked. + * @returns the document with its origin and path, or `none`. + * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or + * `plugin-manager/detail-unavailable`. + */ +@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise + /** Read exact plugin-version exemptions saved in this profile. * @returns Accepted package-name@version keys with the runtime versions they may run on, and any * record or file problem the reader rejected, which the caller reports instead of failing. @@ -191,6 +209,30 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote async inspect(spec: string, options?: InspectOptions, signal?: AbortSignal): Promise +/** Read the published versions newer than an installed registry package's version, from the first registry that answers. + * Official bundles follow the DSH version and are not listed. + * @param name Installed profile dependency name. + * @param options The registry asked first; absent, the one the package was installed from in this process, else the configured one. + * @param signal Ends the lookups early. + * @returns At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration, + * and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked. + */ +@Remote async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise + +/** + * Replace an installed registry bundle with a newer published version. The target is checked on the registry first: + * it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed + * version is removed, so its plugins stop, and the target is installed with the same selection; when that + * installation fails or is cancelled, the removed version is installed again from the registry it came from. + * Without HMR the target is installed over the running version and the result reports the restart. + * @param name Installed profile dependency name. + * @param version Exact published version newer than the installed one. + * @param options The registry asked first, as for `listVersions`, and the request id naming the progress and the + * target installation's logs and cancellation. + * @returns The versions, the last step that ran with its result, and the reinstallation when one ran. + */ +@Remote async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise + /** Persist a plugin entry's desired enablement and apply it on live profiles. * @param id Loader entry identity returned by listPlugins. * @param enabled Whether the plugin should run. @@ -288,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with * or `plugin-market/invalid-index`. */ async entry(url: string, name: string): Promise + +/** + * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file + * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names + * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm + * version without one, of its `package.json` `repository`, at the default branch. An npm version from another + * registry has no readable package files, so only the entry's `repository` is read. + * @param url - a configured index URL. + * @param name - the plugin's package name. + * @param version - one listed version. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names + * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not + * configured are not asked. + * @returns the document with its origin and image document id, or `none`. + * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, + * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`. + */ +@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise ``` Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts) @@ -433,6 +494,24 @@ An installation moved between its Host phases. `installing` is announced once pe Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + +#### `plugin-manager/update-state` — emit + +An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed. Announced once per step. + +```ts cordis-catalog +/** + * An update moved to its next step: checking the target, removing the installed version, installing the target, + * or reinstalling the removed version after the target failed. Announced once per step. + * @mode emit + * @param progress - the update's request id and step. + */ +'plugin-manager/update-state'(progress: PluginUpdateProgress): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + ### `plugin-market/*` events diff --git a/docs/subsystems/computer-use.i18n.yaml b/docs/subsystems/computer-use.i18n.yaml index a0cf7675d0..fc82ebf720 100644 --- a/docs/subsystems/computer-use.i18n.yaml +++ b/docs/subsystems/computer-use.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/computer-use.md /computer-use: - en: a82a6d1ec3fa9724 - zh: 17483872a54d6899 + en: aff84e3ac8c41594 + zh: bee7fc09854bfa8b /computer-use/choose-a-provider: en: fa2e0d7b41c0450b zh: af9a799711f901e6 diff --git a/docs/subsystems/computer-use.md b/docs/subsystems/computer-use.md index 6331c0f492..bc8f1b0491 100644 --- a/docs/subsystems/computer-use.md +++ b/docs/subsystems/computer-use.md @@ -4,9 +4,19 @@ English | [中文](computer-use.zh.md) Computer use lets a model observe and operate the local desktop through a configured provider. The shared DSH capability is called **computer use**; **Cua Driver** names the upstream implementation. +[Computer Use](../../packages/experimental/cua-bundle/README.md) provides an optional experimental switch for macOS CUA permission setup. The [`macOS CUA provider`](../../packages/experimental/cua-macos/README.md) uses the [shared CUA objects and executor](../../packages/experimental/cua/README.md), which enforce application consent through User Questions and profile configuration. +The CUA provider owns `ctx.cuaAccess` for system-permission status, explicit system requests, and application revocation. Its [permission page](../../packages/experimental/cua/README.md) projects these controls; app/window operations enforce the provider's consent policy before dispatch. + +| Permission type | Meaning | +|---|---| +| `CuaApplicationId`, `CuaApplicationGrant` | An executable/signature identity and its displayed saved decision. | +| `CuaSystemPermission` | Accessibility or Screen Recording, selected by the human. | +| `CuaPermissionProcess`, `CuaSystemPermissionState` | Native privilege checks, observed helper/parent processes, and the application identified by macOS process responsibility at helper startup. Application metadata is absent if the optional system query or application lookup is unavailable. | +| `CuaApplicationState` | Saved grants, refused applications, and whether persistence is available. Session-only grants remain internal to the provider. System observations and application icons are queried separately so the page can display each result as it arrives. | + ## Choose a provider Mount [`dsh-computer-use`](../../packages/computer-use/computer-use/README.md) and one provider in the same composition. Both Cua Driver providers are experimental public npm packages and require explicit activation. @@ -56,4 +66,70 @@ register(name: ComputerUseProviderName): () => Promise ``` Source: [`packages/computer-use/computer-use/src/index.ts`](../../packages/computer-use/computer-use/src/index.ts) + + + +### `ctx.cuaAccess` — `CuaAccess` + +Browser controls for provider-owned native permission and application decisions. + +```ts cordis-catalog +/** Read PTC execution state separately from native permission checks. + * @returns Whether the shared runtime retains memory and has an editable profile entry. + */ +@Remote ptcConfiguration(): { persistent: boolean; writable: boolean } + +/** Persist in-memory PTC execution through the existing profile editor and Loader lifecycle. + * @returns Completion after the shared runtime reload; already-persistent runtimes are left intact. + */ +@Remote async enablePersistentPtc(): Promise + +/** + * Read application decisions without waiting for the native helper. + * @returns Saved grants and refused applications with their persistence owner. + */ +@Remote applications(): CuaApplicationState + +/** + * Inspect system privileges independently of application decisions and icons. + * @returns Fresh native preflight and launch metadata. + */ +@Remote async systemStatus(): Promise + +/** + * Load presentation icons separately from application decisions. + * @returns Optional native PNG icons keyed by executable path for saved and blocked applications. + */ +@Remote async applicationIcons(): Promise> + +/** + * Request a system privilege explicitly selected by the user. + * @param permission macOS privilege to request. + * @returns Preflight state after requesting; a false value never implies consent. + */ +@Remote async requestPermission(permission: CuaSystemPermission): Promise + +/** + * Open the macOS privacy pane selected by the user. + * @param permission Corresponding system privilege. + * @returns Completion after macOS accepted the open request. + */ +@Remote async openSettings(permission: CuaSystemPermission): Promise + +/** + * Remove saved and session access and join affected operations. + * @param id Application identity displayed on the permission page. + * @returns Completion after persistence and cancellation; dispatched input cannot be undone. + */ +@Remote revoke(id: CuaApplicationId): Promise + +/** + * Clear this application's refusals across sessions so each session may ask for access again. + * @param id Refused or revoked application displayed on the permission page. + * @returns Completion after clearing refusals; no permission is granted and no action is replayed. + */ +@Remote async retryApproval(id: CuaApplicationId): Promise +``` + +Source: [`packages/experimental/cua/src/access.ts`](../../packages/experimental/cua/src/access.ts) diff --git a/docs/subsystems/computer-use.zh.md b/docs/subsystems/computer-use.zh.md index cd4901bd1a..1323c6b033 100644 --- a/docs/subsystems/computer-use.zh.md +++ b/docs/subsystems/computer-use.zh.md @@ -4,9 +4,19 @@ 计算机操作让模型通过配置的提供方观察并操作本地桌面。DSH 的共享能力称为 **computer use(计算机操作)**;**Cua Driver** 是上游实现的名称。 +[Computer Use](../../packages/experimental/cua-bundle/README.zh.md) 提供 macOS CUA 及权限设置 的可选实验性开关。 [macOS CUA 提供方](../../packages/experimental/cua-macos/README.zh.md)使用[共享 CUA 对象与执行器](../../packages/experimental/cua/README.zh.md),通过 User Questions 和配置档设置执行应用授权。 +CUA provider 通过 `ctx.cuaAccess` 提供系统权限状态、用户主动申请和应用授权撤销。[权限页面](../../packages/experimental/cua/README.zh.md)投影这些控件;app/window 操作在分派前执行 provider 的授权检查。 + +| 权限类型 | 含义 | +|---|---| +| `CuaApplicationId`、`CuaApplicationGrant` | 可执行文件和签名身份,以及显示给用户的已保存决定。 | +| `CuaSystemPermission` | 用户选择申请的辅助功能或屏幕录制权限。 | +| `CuaPermissionProcess`、`CuaSystemPermissionState` | 观测到的 helper、父进程和原生权限状态,以及 helper 启动时由 macOS 责任进程记录识别出的应用。可选系统查询或应用查找不可用时,省略应用信息。 | +| `CuaApplicationState` | 持久授权、拒绝记录,以及能否持久保存。会话授权只保留在 provider 内部。系统权限和应用图标分别查询,页面在各项结果返回后分别显示。 | + ## 选择提供方 在同一组合中挂载 [`dsh-computer-use`](../../packages/computer-use/computer-use/README.zh.md) 和一个提供方。两个 Cua Driver 提供方都是公开发布到 npm 的实验性包,均需显式启用。 @@ -56,4 +66,70 @@ register(name: ComputerUseProviderName): () => Promise ``` Source: [`packages/computer-use/computer-use/src/index.ts`](../../packages/computer-use/computer-use/src/index.ts) + + + +### `ctx.cuaAccess` — `CuaAccess` + +Browser controls for provider-owned native permission and application decisions. + +```ts cordis-catalog +/** Read PTC execution state separately from native permission checks. + * @returns Whether the shared runtime retains memory and has an editable profile entry. + */ +@Remote ptcConfiguration(): { persistent: boolean; writable: boolean } + +/** Persist in-memory PTC execution through the existing profile editor and Loader lifecycle. + * @returns Completion after the shared runtime reload; already-persistent runtimes are left intact. + */ +@Remote async enablePersistentPtc(): Promise + +/** + * Read application decisions without waiting for the native helper. + * @returns Saved grants and refused applications with their persistence owner. + */ +@Remote applications(): CuaApplicationState + +/** + * Inspect system privileges independently of application decisions and icons. + * @returns Fresh native preflight and launch metadata. + */ +@Remote async systemStatus(): Promise + +/** + * Load presentation icons separately from application decisions. + * @returns Optional native PNG icons keyed by executable path for saved and blocked applications. + */ +@Remote async applicationIcons(): Promise> + +/** + * Request a system privilege explicitly selected by the user. + * @param permission macOS privilege to request. + * @returns Preflight state after requesting; a false value never implies consent. + */ +@Remote async requestPermission(permission: CuaSystemPermission): Promise + +/** + * Open the macOS privacy pane selected by the user. + * @param permission Corresponding system privilege. + * @returns Completion after macOS accepted the open request. + */ +@Remote async openSettings(permission: CuaSystemPermission): Promise + +/** + * Remove saved and session access and join affected operations. + * @param id Application identity displayed on the permission page. + * @returns Completion after persistence and cancellation; dispatched input cannot be undone. + */ +@Remote revoke(id: CuaApplicationId): Promise + +/** + * Clear this application's refusals across sessions so each session may ask for access again. + * @param id Refused or revoked application displayed on the permission page. + * @returns Completion after clearing refusals; no permission is granted and no action is replayed. + */ +@Remote async retryApproval(id: CuaApplicationId): Promise +``` + +Source: [`packages/experimental/cua/src/access.ts`](../../packages/experimental/cua/src/access.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a5cc8a18a5..5ab6c342eb 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -38,3 +38,6 @@ /core/repo-wide-type-patterns/branded-ids: en: 10f8ac0f97e7c54a zh: 75cd2311aabfe464 +/core/turnboundaryprojection: + en: 3892ad06e90b25c1 + zh: ee6f592230ad89df diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index de314f1546..4b7c0f3341 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -469,6 +469,26 @@ type Branded = string & { readonly [BRAND]: B } The two core IDs are `ToolCallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Capability packages brand their own ids too, such as `JobId` in [jobs.md](jobs.md). +## `TurnBoundaryProjection` + +`ctx.agents.turnBoundaryOf(session)` reads recorded turn and step positions without requiring a live Agent. The registry owns this state; consumers do not register its projection definition. + +Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** Turn and step positions recorded in a Session. */ +interface TurnBoundaryProjection { + /** Seq of the open turn's `turn/start`, or null between turns. */ + readonly openTurnStartSeq: OptionalSessionSeq + /** Seq of the latest `step/start` event, or null before the first step. */ + readonly lastStepStartSeq: OptionalSessionSeq + /** The latest step boundary (`step/start` or `step/end`) and its seq, or null before the first step boundary. */ + readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: SessionSeq } | null + /** Turn number of the latest `turn/start`; 0 before the first turn. */ + readonly lastTurn: number +} +``` + @@ -576,6 +596,13 @@ Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop Registry of YAML-declared presets and the revisions live Agents retain. ```ts cordis-catalog +/** + * Read the preset recorded for a Session, including selections no longer in the roster. + * @param session - Session whose committed preset selection is read. + * @returns the recorded preset, or null when none was selected. + */ +presetForSession(session: Session): string | null + /** Register and eagerly load a definition; activation failure remains visible in the roster. * @param definition Parsed configuration supplied by the declaring plugin. * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. @@ -665,7 +692,7 @@ async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable> compositionInventory(): Promise ``` -Types: [ScopeKey](scope.md) +Types: [ScopeKey](scope.md) · [Session](session.md) Source: [`packages/preset/agent-preset-registry/src/index.ts`](../../packages/preset/agent-preset-registry/src/index.ts) @@ -678,6 +705,13 @@ Agent service (`ctx.agents`): tracks live agents and carries the initiating Agen Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. ```ts cordis-catalog +/** + * Read the turn and step positions recorded in a Session. + * @param session - Session whose committed events are read. + * @returns the current turn and step positions; do not mutate the returned value. + */ +turnBoundaryOf(session: Session): TurnBoundaryProjection + /** * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution @@ -844,6 +878,8 @@ list(): Agent[] roots(): Agent[] ``` +Types: [Session](session.md) + Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2520a601de..1ad18a947d 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -483,6 +483,26 @@ type Branded = string & { readonly [BRAND]: B } 两个核心 ID 是 `ToolCallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久会话共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [jobs.md](jobs.zh.md) 中的 `JobId`。 +## `TurnBoundaryProjection` + +`ctx.agents.turnBoundaryOf(session)` 读取已记录的轮次和步骤位置,无需活跃 Agent。注册表持有此状态;消费方不注册其投影定义。 + +来源: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** Turn and step positions recorded in a Session. */ +interface TurnBoundaryProjection { + /** Seq of the open turn's `turn/start`, or null between turns. */ + readonly openTurnStartSeq: OptionalSessionSeq + /** Seq of the latest `step/start` event, or null before the first step. */ + readonly lastStepStartSeq: OptionalSessionSeq + /** The latest step boundary (`step/start` or `step/end`) and its seq, or null before the first step boundary. */ + readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: SessionSeq } | null + /** Turn number of the latest `turn/start`; 0 before the first turn. */ + readonly lastTurn: number +} +``` + @@ -590,6 +610,13 @@ Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop Registry of YAML-declared presets and the revisions live Agents retain. ```ts cordis-catalog +/** + * Read the preset recorded for a Session, including selections no longer in the roster. + * @param session - Session whose committed preset selection is read. + * @returns the recorded preset, or null when none was selected. + */ +presetForSession(session: Session): string | null + /** Register and eagerly load a definition; activation failure remains visible in the roster. * @param definition Parsed configuration supplied by the declaring plugin. * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. @@ -679,7 +706,7 @@ async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable> compositionInventory(): Promise ``` -Types: [ScopeKey](scope.zh.md) +Types: [ScopeKey](scope.zh.md) · [Session](session.zh.md) Source: [`packages/preset/agent-preset-registry/src/index.ts`](../../packages/preset/agent-preset-registry/src/index.ts) @@ -692,6 +719,13 @@ Agent service (`ctx.agents`): tracks live agents and carries the initiating Agen Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. ```ts cordis-catalog +/** + * Read the turn and step positions recorded in a Session. + * @param session - Session whose committed events are read. + * @returns the current turn and step positions; do not mutate the returned value. + */ +turnBoundaryOf(session: Session): TurnBoundaryProjection + /** * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution @@ -858,6 +892,8 @@ list(): Agent[] roots(): Agent[] ``` +Types: [Session](session.zh.md) + Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index e5ce0f0902..a55599bd51 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -6,8 +6,8 @@ en: 234f6e4c49714997 zh: add95d36252b572d /plan-mode/logged-state-and-recovery: - en: dd1e8ba31e6fd535 - zh: a22dbccc60c73bc9 + en: 4c83897d7a6d8a67 + zh: 22dcc171a439ed19 /plan-mode/pending-selections-and-the-pre-step-append: en: 8d7e5b7824921ed5 zh: 910fad43593f29f4 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index beba872f61..68e982ec34 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -8,7 +8,7 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s ## Logged state and recovery -`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The optionally registered `plan` unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` reads that state through `stateOf()`; the first dependent access fails if the registry, `plan` key, or `turnBoundary` key is absent. Clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). +`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The `plan` unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` requires the registry and awaits registration of `plan` and `turnBoundary` during activation, then reads through their retained handles. Clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). ## Pending selections and the pre-step append @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. ```ts cordis-catalog /** diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index 63463f0105..25c31455ad 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -8,7 +8,7 @@ ## 已记录状态与恢复 -`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.zh.md):持久且可回放,绝不进入模型 transcript(文本记录)。可选注册的 `plan` 单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 通过 `stateOf()` 读取该状态;注册表、`plan` key 或 `turnBoundary` key 缺失时,第一次依赖它们的访问会失败。客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md)。 +`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.zh.md):持久且可回放,绝不进入模型 transcript(文本记录)。`plan` 单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 要求注册表存在,并在激活期间等待 `plan` 和 `turnBoundary` 注册完成,之后通过保留的句柄读取状态。客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md)。 ## 待生效选择与 pre-step 追加 @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 25f948c599..576f0605c9 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md /session-projections: - en: a6ab5134ca1771fd - zh: 523039a9d84e3f97 + en: e9330151d3eb579a + zh: 53e47acd1d810da9 /session-projections/the-unit: - en: fad897af8b8839ff - zh: 3c68c5256973bc39 + en: d48814d3ab0e997f + zh: d0337d8eba097a63 /session-projections/the-snapshot-and-the-change-feed: - en: a49a57c600c3cbd0 - zh: c92f00b8a9d71bad + en: 630c1041f41f89bf + zh: a3361bdead116cd7 /session-projections/summary-reads: en: d2bc9bcc775acfb7 zh: b1d2ba74496305b8 @@ -18,5 +18,5 @@ en: 6a93a4e1682fd6e6 zh: 28729129c3d8a3b1 /session-projections/the-registry-ctx-sessionprojections: - en: 50e79c1bc16abb63 - zh: 200ecbf0e4ab19d2 + en: 67fe0f49dee90e25 + zh: c23de3a2803040f0 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 69bda39657..8842b63b28 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -2,13 +2,33 @@ English | [中文](session-projection.zh.md) -The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-session-controller](../../packages/api/session-controller)'s history tail page and `session/projection` push frame). It is one optional capability, not part of the agent-loop spine. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). +The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-session-controller](../../packages/api/session-controller)'s history tail page and `session/projection` push frame). Host state readers require the registry during activation. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) +A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots, checkpoints, or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md). + +```ts type-equiv +/** An effect-owned registration with a reader retained independently of registration. */ +interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise +} +``` + ## The unit -`SessionProjectionStateMap` is the merge-extensible table of host fold states, while `SessionProjectionMap` retains the client-visible whole values. A domain contributes one `ProjectionDefinition` per state key; a `wire` block makes that key client-visible, and rendering belongs to the slot system, never this layer: +`ProjectionDefinition` carries each unit's host state type locally; registration infers `S` from `stateSchema` and returns `ProjectionHandle`. Only client-visible keys merge into `SessionProjectionMap`, which types their whole values. A `wire` block makes a unit client-visible, and rendering belongs to the slot system, never this layer: ```ts type-equiv /** @@ -19,11 +39,8 @@ Source: [`packages/session/session-projection/src/index.ts`](../../packages/sess * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */ -interface ProjectionDefinition< - K extends keyof SessionProjectionStateMap, - S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K], -> { - /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ +interface ProjectionDefinition { + /** The projection key this unit owns. */ key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType @@ -107,7 +124,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `handle.stateOf(session)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. ## Summary reads @@ -187,7 +204,7 @@ type ProjectionKeySelection = ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, while retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration. @@ -288,26 +305,27 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void +register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): () => void +register( definition: ProjectionDefinition & { key: Exclude }, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the @@ -317,16 +335,6 @@ register< K extends Exclude void -/** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ -stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 8385dddb3b..c6e2c951fe 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -2,13 +2,33 @@ [English](session-projection.md) | 中文 -会话投影 seam 是一项[能力 seam](../capability-seams.zh.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-session-controller](../../packages/api/session-controller) 的历史尾页与 `session/projection` 推送帧)。它是一项可选能力,不属于 agent loop(智能体循环)主干。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.zh.md)。 +会话投影 seam 是一项[能力 seam](../capability-seams.zh.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-session-controller](../../packages/api/session-controller) 的历史尾页与 `session/projection` 推送帧)。Host 状态读取方在激活期间要求注册表存在。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.zh.md)。 源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) +`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照、检查点或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。 + +```ts type-equiv +/** An effect-owned registration with a reader retained independently of registration. */ +interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise +} +``` + ## 投影单元 -`SessionProjectionStateMap` 是 host 侧折叠状态的 merge-extensible 类型表,`SessionProjectionMap` 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 `ProjectionDefinition`;`wire` 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层: +`ProjectionDefinition` 在各单元内部携带宿主状态类型;注册从 `stateSchema` 推导 `S`,并返回 `ProjectionHandle`。只有客户端可见的 key 合并到 `SessionProjectionMap`,由它约束对应的全量值。`wire` 块使单元对客户端可见,渲染归 slot 体系管,永远不归本层: ```ts type-equiv /** @@ -19,11 +39,8 @@ * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */ -interface ProjectionDefinition< - K extends keyof SessionProjectionStateMap, - S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K], -> { - /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ +interface ProjectionDefinition { + /** The projection key this unit owns. */ key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType @@ -107,7 +124,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`handle.stateOf(session)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 ## 摘要读取 @@ -187,7 +204,7 @@ type ProjectionKeySelection = ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 `stateVersion` 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。 @@ -288,26 +305,27 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void +register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): () => void +register( definition: ProjectionDefinition & { key: Exclude }, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the @@ -317,16 +335,6 @@ register< K extends Exclude void -/** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ -stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index 1e666632b5..f205395403 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.i18n.yaml @@ -11,3 +11,6 @@ /token-meter/tokensurfacenode: en: e445d12b10f651d0 zh: e93be5fb9da1da15 +/token-meter/contextpressureprojection: + en: 37e477a82a9c3a83 + zh: ad7143becb29afe9 diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index 8accb9cf9f..1aa56a8d24 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -53,6 +53,44 @@ interface TokenSurfaceNode { Surface order is authoritative; replacement nodes can have higher durable seqs than later positional nodes. The snapshot is immutable and does not grow when the underlying replay fold advances. +## `ContextPressureProjection` + +`ctx.tokenMeter.contextPressure(session)` synchronously reads the latest logged `pressureTokens` and `contextWindow`; the client projection also derives `projectedTokens` from surface changes. These display values do not price a new request; use `measure()` for request pricing. + +Source: [`packages/llm/token-meter/src/projection.ts`](../../packages/llm/token-meter/src/projection.ts) + +```ts type-equiv +/** + * Approximate context occupancy for a status display. + * + * The fields, when present, are deliberately NOT one atomic request + * observation: each is a last-wins record of a different moment. Switching + * models can therefore pair a fresh capacity with the previous route's + * pressure until the next request reports usage. This is an intentional trade + * — the value is a user-facing reference, not a billing or gating input. See + * the token-meter README for the full rationale. + */ +interface ContextPressureProjection { + /** + * Provider-reported prompt size of the most recent request: uncached input + * plus cache reads and writes. Response output is excluded, so this does not + * grow as the current turn streams. Absent until a provider reports usage. + */ + pressureTokens?: number + /** + * What the NEXT request's prompt would cost: {@link pressureTokens} plus the + * heuristic repricing of everything the surface gained or lost since that + * sample. Only the delta is estimated, so the figure stays anchored to the + * provider while still reacting the moment a compaction shadows a span — + * which `pressureTokens` alone cannot do, since compaction reports no usage + * of its own. Absent until a provider reports usage. + */ + projectedTokens?: number + /** Newest recorded route capacity; absent when no adapter advertised one. */ + contextWindow?: number +} +``` + @@ -68,6 +106,13 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog +/** + * Read the latest logged provider prompt size and route capacity. + * @param session - Session whose committed usage and request capacity are read. + * @returns context pressure; fields remain absent until their source events are recorded. + */ +contextPressure(session: Session): Pick + /** * Measure current request pressure and surface through the durable tail. * diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index 6c149ec3f2..970f90d9dd 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -53,6 +53,44 @@ interface TokenSurfaceNode { 表层顺序具有权威性;替换节点的持久 seq 可能高于位置排在其后的节点。该快照不可变,不会随底层回放折叠推进而增长。 +## `ContextPressureProjection` + +`ctx.tokenMeter.contextPressure(session)` 同步读取最新记录的 `pressureTokens` 和 `contextWindow`;客户端投影还根据表层变化派生 `projectedTokens`。这些显示值不为新请求计价;请求计价使用 `measure()`。 + +来源: [`packages/llm/token-meter/src/projection.ts`](../../packages/llm/token-meter/src/projection.ts) + +```ts type-equiv +/** + * Approximate context occupancy for a status display. + * + * The fields, when present, are deliberately NOT one atomic request + * observation: each is a last-wins record of a different moment. Switching + * models can therefore pair a fresh capacity with the previous route's + * pressure until the next request reports usage. This is an intentional trade + * — the value is a user-facing reference, not a billing or gating input. See + * the token-meter README for the full rationale. + */ +interface ContextPressureProjection { + /** + * Provider-reported prompt size of the most recent request: uncached input + * plus cache reads and writes. Response output is excluded, so this does not + * grow as the current turn streams. Absent until a provider reports usage. + */ + pressureTokens?: number + /** + * What the NEXT request's prompt would cost: {@link pressureTokens} plus the + * heuristic repricing of everything the surface gained or lost since that + * sample. Only the delta is estimated, so the figure stays anchored to the + * provider while still reacting the moment a compaction shadows a span — + * which `pressureTokens` alone cannot do, since compaction reports no usage + * of its own. Absent until a provider reports usage. + */ + projectedTokens?: number + /** Newest recorded route capacity; absent when no adapter advertised one. */ + contextWindow?: number +} +``` + @@ -68,6 +106,13 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog +/** + * Read the latest logged provider prompt size and route capacity. + * @param session - Session whose committed usage and request capacity are read. + * @returns context pressure; fields remain absent until their source events are recorded. + */ +contextPressure(session: Session): Pick + /** * Measure current request pressure and surface through the durable tail. * diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index c281046a9c..db5df733f7 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: dec5cbb4c122c7e7 - zh: 90edee4efc205cf3 + en: 19883cdd5d6181cb + zh: 7d0cac18206184ec /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 2b07c4659f..02eb34af2b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -21,9 +21,9 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | -| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | +| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agents`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`, `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. | -| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.workingDirectory`, `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | Creator mode provides two read-only runtime inspection tools. The Cordis host runner supplies the inspection registry; Client queries require a connected page. Author persistent changes as bundles and install them with plugin_manager. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.workingDirectory`, `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | @@ -37,7 +37,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.workingDirectory`, `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-stdio`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`, `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | -| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `ctx.agents`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | The subagent_oar tool selects among configured aliases for external providers. This catalog shows Codex and Claude Code aliases; each deployment chooses its providers. Native model and permission settings belong to the selected provider configuration. | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.sessionProjections`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while one instance of this package registers `send_message`, `interrupt_agent`, and `list_agents`. | diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 0067cef18b..2a911704a3 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -25,9 +25,9 @@ | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | -| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | +| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agents`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`、`ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 | -| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`、`ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`、`ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.workingDirectory`、`ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | 创造模式提供两个只读运行时检查工具。Cordis host runner 提供检查注册表;Client 查询需要已连接页面。持久化变更编写为组合包,再通过 plugin_manager 安装。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.workingDirectory`、`ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | @@ -41,7 +41,7 @@ | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.workingDirectory`、`ctx.tools`、`ctx.lsp`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,因此其模型可见 schema 在更换提供方时保持稳定。运行时要求已注册提供方,例如 `@deepseek-ai/dsh-lsp-stdio`;如果没有提供方,查询会返回结构化 `LSP_UNAVAILABLE` 错误,而不会改变 schema。 | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`、`ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | -| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`ctx.agents`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | subagent_oar 工具在为外部提供方配置的别名之间选择。本目录展示 Codex 和 Claude Code 别名;每个部署自行选择提供方。原生模型和权限设置由所选提供方的配置决定。 | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`ctx.sessionProjections`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包的一个实例注册 `send_message`、`interrupt_agent` 和 `list_agents`。 | diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.i18n.yaml new file mode 100644 index 0000000000..d4239e1fca --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.md +/: + en: ce2254ec04c7e121 + zh: d41be1b049886b05 +/viewprofilepackage-takes-the-fields-it-prints: + en: 2b42139fb8291351 + zh: 142078b83eec688c +/viewprofilepackage-takes-the-fields-it-prints/change: + en: 72177a0fbf9d18de + zh: 0e689154933a6174 +/viewprofilepackage-takes-the-fields-it-prints/migration: + en: 7107ae7573558a60 + zh: 17c4428c0e83a842 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.md new file mode 100644 index 0000000000..744423b99d --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "viewProfilePackage from @deepseek-ai/dsh-plugin-manager/operations requires the manifest fields it prints." +--- + +# `viewProfilePackage` takes the fields it prints + +English | [中文](guide.zh.md) + +## Change + +`viewProfilePackage(dir, spec, options)` from `@deepseek-ai/dsh-plugin-manager/operations` used to print a fixed field list: `name`, `version`, `description`, and `dsh`. `PackageViewOptions` now requires `fields`, the manifest or registry fields `pnpm view` prints, so the same lookup reads version lists and per-version peers. Code that calls `viewProfilePackage` directly no longer type-checks without `fields`, and a JavaScript caller that omits it runs `pnpm view` with no fields. + +## Migration + +1. Pass the fields the call used to print: + + ```ts + import { viewProfilePackage } from '@deepseek-ai/dsh-plugin-manager/operations' + + declare const dir: string, spec: string, timeoutMs: number + await viewProfilePackage(dir, spec, { fields: ['name', 'version', 'description', 'dsh'], timeoutMs }) + ``` + +2. Run your type checker; a missing `fields` property is reported at each call. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.zh.md new file mode 100644 index 0000000000..067d90bc6e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.zh.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "@deepseek-ai/dsh-plugin-manager/operations 的 viewProfilePackage 需要传入它打印的清单字段。" +--- + +# `viewProfilePackage` 接收它打印的字段 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-plugin-manager/operations` 的 `viewProfilePackage(dir, spec, options)` 原先打印固定的字段列表:`name`、`version`、`description` 与 `dsh`。`PackageViewOptions` 现在要求 `fields`,即 `pnpm view` 打印的清单或注册表字段,使同一查询可以读取版本列表和各版本的 peer。直接调用 `viewProfilePackage` 的代码缺少 `fields` 时无法通过类型检查,省略它的 JavaScript 调用方会让 `pnpm view` 不带字段运行。 + +## 迁移 + +1. 传入调用原先打印的字段: + + ```ts + import { viewProfilePackage } from '@deepseek-ai/dsh-plugin-manager/operations' + + declare const dir: string, spec: string, timeoutMs: number + await viewProfilePackage(dir, spec, { fields: ['name', 'version', 'description', 'dsh'], timeoutMs }) + ``` + +2. 运行类型检查;缺少 `fields` 属性的每处调用都会被报告。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml new file mode 100644 index 0000000000..6918fdf4c1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md +/: + en: b3f54a7eadb3398a + zh: 6815b996b2cebc6f +/session-projection-registration-handles: + en: 5cf8cb0051860df5 + zh: 2578c6c3e77bc1c7 +/session-projection-registration-handles/change: + en: 64c2492c43c7a263 + zh: ba5a15b959df496c +/session-projection-registration-handles/migration: + en: bd4310bb946b6f08 + zh: 2086e6e9c11bc4ed diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md new file mode 100644 index 0000000000..73734634e3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session projection registration is asynchronous and returns a retained reader instead of a disposer." +--- +# Session projection registration handles + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise>`. Registry-level `stateOf(session, key)` and `SessionProjectionStateMap` are removed. Host state types stay local to `ProjectionDefinition` and are inferred from `stateSchema` during registration. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand. + +`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state; consumers use `ctx.agents.turnBoundaryOf(session)` instead of importing `turnBoundaryProjectionDefinition`. Turn-state consumers, including the Claude Code mods bridge, require `agents`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` also removes `installModelSelectionProjection`. + +## Migration + +1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Include `@deepseek-ai/dsh-agent` wherever a plugin requires `agents`. Consumers of domain data inject the owning service. +2. Remove host-state map augmentations and imports; pass the local state type as the second `ProjectionDefinition` argument. Keep client-value declarations in `SessionProjectionMap`. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle privately before publishing dependent operations. Keep existing function-plugin namespace exports. +3. Replace `registry.stateOf(session, key)` with a business service method for another domain, or `handle.stateOf(session)` inside the owner. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. +4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. +5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone. diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md new file mode 100644 index 0000000000..d79783d036 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session 投影注册改为异步,并返回保留读取能力的句柄而不是释放函数。" +--- +# Session 投影注册句柄 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise>`。注册表级 `stateOf(session, key)` 与 `SessionProjectionStateMap` 已移除。宿主状态类型保留在各自的 `ProjectionDefinition` 中,注册时从 `stateSchema` 推导。注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。 + +`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态;消费方使用 `ctx.agents.turnBoundaryOf(session)`,不再导入 `turnBoundaryProjectionDefinition`。包括 Claude Code mods 桥接在内的轮次状态消费方依赖 `agents`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 同时移除了 `installModelSelectionProjection`。 + +## 迁移 + +1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。组合中的插件依赖 `agents` 时,加入 `@deepseek-ai/dsh-agent`。领域数据的消费方注入所属服务。 +2. 删除宿主状态映射的声明合并与导入,将局部状态类型作为 `ProjectionDefinition` 的第二个类型参数。保留 `SessionProjectionMap` 中的客户端值声明。对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前于内部保存句柄。保留现有函数插件命名空间导出。 +3. 将 `registry.stateOf(session, key)` 替换为其他领域的业务服务方法,或所有者内部的 `handle.stateOf(session)`。将保存的取消注册回调替换为始终返回 `Promise` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动释放注册。处理初始化失败时须等待注册。 +4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 +5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.i18n.yaml new file mode 100644 index 0000000000..7313c9c61a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.md +/: + en: 36d6b79b48c08dd5 + zh: 814caece159e9b0e +/cua-defaults-to-ptc-session-scopes: + en: a53b6884785bdf56 + zh: 7d27fc9b029e0e3e +/cua-defaults-to-ptc-session-scopes/change: + en: dac96a29e3751249 + zh: d48cb030f43c07e4 +/cua-defaults-to-ptc-session-scopes/migration: + en: f3d82fe45e41c712 + zh: e55e424f5a2c096a diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.md new file mode 100644 index 0000000000..7db56c3afd --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Standalone native CUA compositions must explicitly select global tool registration." +--- + +# CUA defaults to PTC session scopes + +English | [中文](guide.zh.md) + +## Change + +The native CUA provider defaults `agentPreset` to `ptc`. It registers tools for existing and new PTC Agents, including when a saved application-grants configuration omits the new field. Custom compositions without an Agent preset registry no longer receive global CUA tools by default. + +## Migration + +1. For the standard Computer Use switch, keep the default; existing application grants remain usable. +2. For a standalone composition that needs global tools, add `agentPreset: false` under the native CUA entry’s `config` in its profile patch or overlay. Preserve its other fields, including `allowedApps` and `helperPath`. +3. Restart or let HMR apply the edit, then confirm that the intended Agent exposes `cua` in native mode or through the PTC SDK. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.zh.md new file mode 100644 index 0000000000..ff6f6ccbc0 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-preset-scope/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "独立原生 CUA 组合需要显式选择全局工具注册。" +--- + +# CUA 默认使用 PTC 会话作用域 + +[English](guide.md) | 中文 + +## 变更 + +原生 CUA 提供方的 `agentPreset` 默认为 `ptc`。工具注册到已有和新建 PTC Agent,即使保存的应用授权配置没有这个新字段也会生效。不包含 Agent preset 注册表的自定义组合不再默认获得全局 CUA 工具。 + +## 迁移 + +1. 使用标准 Computer Use 开关时保持默认值,已有应用授权仍可使用。 +2. 独立组合若需要全局工具,在 profile patch 或 overlay 的原生 CUA 条目 `config` 下添加 `agentPreset: false`,保留 `allowedApps`、`helperPath` 等其他字段。 +3. 重启或等待 HMR 应用后,确认目标 Agent 能在原生模式或 PTC SDK 中使用 `cua`。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.i18n.yaml new file mode 100644 index 0000000000..5a624e1331 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md +/: + en: 61a3259e0c1e7893 + zh: 0c96fdeebcb2368f +/pi-ai-default-model-capacities: + en: cc30c7c81bcb89eb + zh: ee0b1b5f82676f9e +/pi-ai-default-model-capacities/change: + en: a47430fe02282532 + zh: 4f8d65358bb36431 +/pi-ai-default-model-capacities/migration: + en: ca3e5882f43f88c3 + zh: 89d939f046a6ee1d diff --git a/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md new file mode 100644 index 0000000000..fe2a2d9ce4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Undescribed pi-ai models inherit larger context and output capacities unless the provider configures explicit defaults." +--- + +# pi-ai default model capacities + +English | [中文](guide.zh.md) + +## Change + +For models whose capacity is absent from both configuration and the installed catalog, `llm-pi-ai` raises `defaultContextWindow` from 262,144 to 1,000,000 tokens and `defaultMaxTokens` from 32,768 to 128,000 tokens. A larger assumed context window can delay automatic compaction. Explicit model capacities, catalog values, and provider defaults retain their precedence. The output-capacity fallback does not become a Harness request-level `maxTokens` default. + +## Migration + +1. Check the actual limits of custom endpoints. In the `llm-pi-ai` configuration in `cordis.patch.yml`, set `providers..defaultContextWindow` and `providers..defaultMaxTokens` when those limits differ. To retain the previous defaults, use `262144` and `32768` respectively. +2. For models with different limits on the same provider, set `contextWindow` and `maxTokens` on their `providers..models` entries. An explicitly configured model `maxTokens` also supplies the Harness request default. +3. Reopen the profile configuration and confirm the saved numeric values. See the [provider configuration reference](../../../../packages/llm/llm-pi-ai/README.md). diff --git a/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.zh.md new file mode 100644 index 0000000000..ddf3d8af35 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "配置和目录均未描述容量的 pi-ai 模型会继承更大的上下文与输出容量,除非提供方显式配置默认值。" +--- + +# pi-ai 默认模型容量 + +[English](guide.md) | 中文 + +## 变更 + +对于配置和已安装目录均未提供容量的模型,`llm-pi-ai` 将 `defaultContextWindow` 从 262,144 提高到 1,000,000 tokens,将 `defaultMaxTokens` 从 32,768 提高到 128,000 tokens。更大的假定上下文窗口可能推迟自动压缩。显式模型容量、目录值和提供方默认值仍保留原有优先级。输出容量回退值不会成为 Harness 请求级 `maxTokens` 默认值。 + +## 迁移 + +1. 核对自定义端点的实际限制。如果限制不同,在 `cordis.patch.yml` 的 `llm-pi-ai` 配置中设置 `providers..defaultContextWindow` 和 `providers..defaultMaxTokens`。如需保留原默认值,分别填写 `262144` 和 `32768`。 +2. 同一提供方的模型限制不同时,在相应 `providers..models` 条目上设置 `contextWindow` 和 `maxTokens`。显式配置的模型 `maxTokens` 同时提供 Harness 请求默认值。 +3. 重新打开 profile 配置,确认保存的数值。参见[提供方配置参考](../../../../packages/llm/llm-pi-ai/README.zh.md)。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.i18n.yaml new file mode 100644 index 0000000000..3f4590b5d7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.md +/: + en: c81d35a7d1605742 + zh: 49a149c848fdbb35 +/sdk-minimal-requires-durable-checkpoints: + en: a238e25e459a20cd + zh: 871ee52936d06b24 +/sdk-minimal-requires-durable-checkpoints/change: + en: 3ba980ee74955e34 + zh: 495fe6c0dd9001bf +/sdk-minimal-requires-durable-checkpoints/migration: + en: 43c3d55a0fa7a562 + zh: f2b087fb1387b517 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.md new file mode 100644 index 0000000000..f842d62766 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The sdk-minimal profile now stops model and tool dispatch when session persistence cannot complete a checkpoint." +--- + +# SDK-minimal requires durable checkpoints + +English | [中文](guide.zh.md) + +## Change + +The `sdk-minimal` profile enables `session-checkpoint-policy`. It waits for session history to flush before model requests, top-level tool execution, and agent steps. Previously these operations could proceed while JSONL writes were buffered; persistence errors could remain background warnings. A checkpoint failure now stops the dependent operation. First-use file-lock initialization is paid before the first model request, and checkpoint writes contribute to request latency. This does not change the Session format or guarantee that forced termination preserves the final unflushed response. + +## Migration + +1. Keep the selected `dsh_home` writable and allow request deadlines to cover storage initialization and checkpoint writes. A longer shutdown timeout alone does not affect a pre-request checkpoint. +2. Check profile and invocation patches for overrides of `session-checkpoint-policy`. To receive the durability policy, retain its enabled default alongside `sessions`. +3. Run a short SDK turn, close normally, and use `resume_session()` with the same home and session ID to verify restoration. Investigate checkpoint failures before retrying model or tool work; the operation does not run until the checkpoint succeeds. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.zh.md new file mode 100644 index 0000000000..c55ade454a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "sdk-minimal profile 现在会在会话持久化无法完成检查点时停止模型和工具调度。" +--- + +# SDK-minimal 要求持久化检查点 + +[English](guide.md) | 中文 + +## 变更 + +`sdk-minimal` profile 启用 `session-checkpoint-policy`,在模型请求、顶层工具执行和 agent step 之前等待会话历史刷盘。此前,JSONL 写入尚在缓冲时这些操作也可以继续,持久化错误可能只记录为后台警告。现在,检查点失败会停止依赖它的操作。首次文件锁初始化发生在第一次模型请求之前,检查点写入也计入请求延迟。此变更不修改 Session 格式,也不保证强制终止时保留最后尚未刷盘的回复。 + +## 迁移 + +1. 确保选定的 `dsh_home` 可写,并让请求超时覆盖存储初始化和检查点写入。仅延长关闭超时不会影响请求前的检查点。 +2. 检查 profile 和调用级 patch 是否覆盖了 `session-checkpoint-policy`。要使用此持久化策略,应在 `sessions` 旁保留其默认启用状态。 +3. 运行一轮 SDK 短对话,正常关闭,再使用相同 home 和会话 ID 调用 `resume_session()` 验证恢复。重试模型或工具操作前,先排查检查点失败;只有检查点成功后,操作才会执行。 diff --git a/package.json b/package.json index 39c7f94f95..fd29dfb45f 100644 --- a/package.json +++ b/package.json @@ -235,6 +235,7 @@ "@deepseek-ai/dsh-cua": "workspace:*", "@deepseek-ai/dsh-experimental-claude-code-mods": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-mcp-client": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-web-fetch-http": "workspace:*", @@ -290,6 +291,7 @@ "@deepseek-ai/dsh-working-directory": "workspace:*", "@deepseek-ai/dsh-experimental-worktree": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", + "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-tool-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*" diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts index 85609166b0..4a8fca3bfe 100644 --- a/packages/api/job-controller/tests/host-harness.ts +++ b/packages/api/job-controller/tests/host-harness.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobAppendOptions, JobHandle, JobOutcome } from '@deepseek-ai/dsh-jobs' @@ -9,6 +10,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' /** A registry with a tiny ring so eviction is cheap to reach. */ export async function harness(): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) ctx.jobs.attachController('job-controller-test') diff --git a/packages/api/job-controller/tests/kill.host.spec.ts b/packages/api/job-controller/tests/kill.host.spec.ts index 2a14afac2d..bfaf0c2141 100644 --- a/packages/api/job-controller/tests/kill.host.spec.ts +++ b/packages/api/job-controller/tests/kill.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobId, JobOutcome } from '@deepseek-ai/dsh-jobs' @@ -45,6 +46,7 @@ async function harness(): Promise<{ }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('kill-test') diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index 583862eeac..92c680ed07 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId } from '@deepseek-ai/dsh-jobs' @@ -215,6 +216,7 @@ describe('JobController', () => { async function controllerHarness() { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(TypertRegistry) @@ -247,6 +249,7 @@ describe('JobController', () => { it('waits for the job registry instead of serving without one', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TypertRegistry) ctx.plugin(JobController, {}) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 90fc6b7fde..da6f814458 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -32,10 +32,11 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { - BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, - PackageResult, + BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, + ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, + PluginUpdateProgress, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, RegistryAttempt, ReadOnlyReason, Registry, RestartReason, } from '@deepseek-ai/dsh-plugin-manager/types' export type {} from '@deepseek-ai/dsh-plugin-manager/remote' @@ -43,7 +44,7 @@ export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote' export type {} from '@deepseek-ai/dsh-plugin-market/remote' export type { MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketInstallationStatus, MarketListingEntry, MarketListingVersion, - MarketQuery, MarketSearchResult, MarketSort, + MarketQuery, MarketSearchResult, MarketSort, PluginDetail, PluginDetailDocumentId, PluginDetailOrigin, } from '@deepseek-ai/dsh-plugin-market/types' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote' diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 545c4a47d2..ef4ac40a45 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -48,6 +48,7 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'plugin-manager/changed', mode: 'emit' }, { event: 'plugin-manager/install-log', mode: 'emit' }, { event: 'plugin-manager/install-state', mode: 'emit' }, + { event: 'plugin-manager/update-state', mode: 'emit' }, { event: 'plugin-market/changed', mode: 'emit' }, { event: 'settings/document-updated', mode: 'emit' }, { event: 'schedule/changed', mode: 'emit' }, diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index d8a89c0b65..2c22aff84f 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -86,10 +86,10 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { }) await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) await host.plugin(TypertRegistry) + await host.plugin(SessionProjectionRegistry) await host.plugin(AgentRegistry) await host.plugin(RemoteAccessService) await host.plugin(TypertRemoteService) - await host.plugin(SessionProjectionRegistry) await host.plugin(GoalService) host.typert.register(TYPERT) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index e8afb243af..5934e5eded 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 9348161f652592ed - zh: d9c4919381bf0f77 + en: 02bb77a6008bcdd7 + zh: 48b0d8782f2a3d77 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index b2cf8e4ff5..115130dcfc 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,6 +27,8 @@ Desktop analytics follows the [product collection policy](../../client/product-a ## Use this package +The controller reads a Session's recorded preset through the optional `agentPresets.presetForSession(session)` service method. A null result means no preset was recorded; an absent service means preset identity is unavailable to the controller. + History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index c969f9f953..d8566f7ad0 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" ## 使用本包 +控制器通过可选服务方法 `agentPresets.presetForSession(session)` 读取 Session 已记录的预设。null 结果表示未记录预设;服务缺席表示控制器无法获知预设身份。 + 历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。 Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 4ecd2af8ec..0900765d7d 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -16,7 +16,9 @@ import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' -import type { ModelSelection } from './types.ts' +import type { ModelSelection, ModelSelectionProjectionState } from './types.ts' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { modelSelectionProjection } from './model-selection-projection.ts' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} @@ -168,13 +170,22 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { + private modelSelectionProjectionHandle!: ProjectionHandle private readonly resumes = new Map>() private readonly claims = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ - constructor(private readonly ctx: Context) { + constructor(private readonly ctx: Context) {} + + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + const ctx = this.ctx + this.modelSelectionProjectionHandle = await ctx.sessionProjections.register(modelSelectionProjection) ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error @@ -289,10 +300,7 @@ export class ApiSessionAgentController { selectionFor(agent: Agent): InstalledSelection { const installed = this.selections.get(agent) if (installed !== undefined) return installed - const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection') - if (projectionState === undefined) { - throw new Error('api-session: required modelSelection projection is not registered') - } + const projectionState = this.modelSelectionProjectionHandle.stateOf(agent.session) let picked = projectionState.pending === null ? undefined : agentModelSelection(projectionState.pending) @@ -361,10 +369,10 @@ export class ApiSessionAgentController { /** * Read the current Agent preset from the Session projection. * @param session - live Session whose projection state is available. - * @returns the current preset, or undefined when the capability is absent. + * @returns the current preset, or undefined without a preset service or recorded selection. */ presetForSession(session: Session): string | undefined { - return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined + return this.ctx.get('agentPresets')?.presetForSession(session) ?? undefined } /** @@ -563,8 +571,8 @@ export class ApiSessionAgentController { /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */ private isBlank(session: Session): boolean { - const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') - return boundary === undefined || (boundary.openTurnStartSeq === null && boundary.lastTurn === 0) + const boundary = this.ctx.agents.turnBoundaryOf(session) + return boundary.openTurnStartSeq === null && boundary.lastTurn === 0 } /** Without a roster, reuse cannot apply a named preset. */ diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 6fb113e3cb..d060eab5fb 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -4,7 +4,7 @@ import { hostname } from 'node:os' import { resolve } from 'node:path' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' @@ -28,7 +28,6 @@ import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList } from './list.ts' import { buildModelCatalog, hasProviderApiKey } from './catalog.ts' -import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' import { ArchivedSessionGate } from './archived-session-gate.ts' @@ -138,16 +137,17 @@ export class SessionController extends TypertRemoteService { listWorkSliceMs: z.natural().min(1).default(16), }) - private readonly agents: ApiSessionAgentController - private readonly commands: SessionCommandController - private readonly controlState: SessionControlController - private readonly history: SessionHistoryController - private readonly listState: ApiSessionList + private agents!: ApiSessionAgentController + private commands!: SessionCommandController + private controlState!: SessionControlController + private history!: SessionHistoryController + private listState!: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly fileApplications: typeof nativeFileApplications private readonly openFileApplication: typeof openNativeFileApplication private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean + private readonly listWorkSliceMs: number private readonly promotions = new Set>() /** @@ -157,10 +157,20 @@ export class SessionController extends TypertRemoteService { */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) + this.listWorkSliceMs = SessionController.Config(config).listWorkSliceMs + this.fileApplications = internals.fileApplications ?? nativeFileApplications + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication + this.openPath = internals.openPath ?? openNativeAssociatedPath + this.revealPath = internals.revealPath ?? revealNativePath + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx ctx.typert.resources.register('session', { field: 'sessionId' }) - const resolved = SessionController.Config(config) - installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) + await this.agents.initialize() this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { const result = await this.agents.resolveAgent(sessionId) @@ -174,13 +184,8 @@ export class SessionController extends TypertRemoteService { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) - this.listState = new ApiSessionList(ctx, resolved.listWorkSliceMs) - this.fileApplications = internals.fileApplications ?? nativeFileApplications - this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication - this.openPath = internals.openPath ?? openNativeAssociatedPath - this.revealPath = internals.revealPath ?? revealNativePath - this.canOpenPath = internals.canOpenPath - ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + this.listState = new ApiSessionList(ctx, this.listWorkSliceMs) + await this.listState.initialize() ctx.plugin(SessionFileReferences) ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index d14c4fbd17..d258ace5c8 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -80,8 +80,14 @@ export class ApiSessionList { * @param ctx - Host context carrying Session, query, persistence, and projection services. * @param workSliceMs - Resolved positive integral list-work budget in milliseconds. */ - constructor(private readonly ctx: Context, private readonly workSliceMs: number) { - ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ + constructor(private readonly ctx: Context, private readonly workSliceMs: number) {} + + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ key: 'sessionListMetadata', stateSchema: sessionListMetadataSchema, init: () => ({ blank: true, lastPromptAt: null }), @@ -89,19 +95,17 @@ export class ApiSessionList { wire: { viewSchema: sessionListMetadataSchema, view: state => state }, stateVersion: 1, }) - ctx.inject(['attachments'], (attachmentCtx) => { - ctx.sessionProjections.register<'imageLimits', null>({ - key: 'imageLimits', - stateSchema: z.null(), - init: () => null, - apply: state => state, - wire: { - viewSchema: imageLimitsSchema, - view: () => attachmentCtx.attachments.imageLimits, - summary: false, - }, - stateVersion: 1, - }) + await this.ctx.sessionProjections.register<'imageLimits', null>({ + key: 'imageLimits', + stateSchema: z.null(), + init: () => null, + apply: state => state, + wire: { + viewSchema: imageLimitsSchema, + view: () => this.ctx.attachments.imageLimits, + summary: false, + }, + stateVersion: 1, }) } diff --git a/packages/api/session-controller/src/model-selection-projection.ts b/packages/api/session-controller/src/model-selection-projection.ts index 47eddde181..071cf5e685 100644 --- a/packages/api/session-controller/src/model-selection-projection.ts +++ b/packages/api/session-controller/src/model-selection-projection.ts @@ -1,6 +1,5 @@ /** Durable model-selection intent and request-use projection. */ -import type { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { z } from 'zod' @@ -55,7 +54,8 @@ function applyModelSelectionProjection( : { lastUsed, pending } } -const modelSelectionProjection = { +/** Last-used and pending model selections shared by Session controller readers. */ +export const modelSelectionProjection = { key: 'modelSelection', stateSchema: modelSelectionProjectionStateSchema, init: () => ({ lastUsed: null, pending: null }), @@ -73,11 +73,3 @@ function sameSelection(left: ModelSelection | null, right: ModelSelection | null && left.model === right.model && left.reasoningEffort === right.reasoningEffort) } - -/** - * Register the durable model-selection projection when the registry is present. - * @param ctx - Session Controller context. - */ -export function installModelSelectionProjection(ctx: Context): void { - ctx.sessionProjections.register(modelSelectionProjection) -} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 84661924a6..661054dbae 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -14,14 +14,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Host state persisted for cold Session list summaries. */ - sessionListMetadata: SessionListMetadata - /** Host state for the boot-constant image-limit view. */ - imageLimits: null - /** Durable model selection already used by a request and still pending for a later request. */ - modelSelection: ModelSelectionProjectionState - } interface SessionProjectionMap { /** Persisted facts used to summarize a Session without activating it. */ sessionListMetadata: SessionListMetadata diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 40dceeefb9..9dba50297c 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' @@ -17,7 +18,6 @@ import { ApiSessionNotFound, inspectApiSession, } from '../src/agent.ts' -import { installModelSelectionProjection } from '../src/model-selection-projection.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const roots: Context[] = [] @@ -35,15 +35,17 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl roots.push(ctx) await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - installModelSelectionProjection(ctx) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), saveSelection: () => Promise.resolve(), } as never) - return { ctx, agents: new ApiSessionAgentController(ctx) } + const agents = new ApiSessionAgentController(ctx) + await agents.initialize() + return { ctx, agents } } function header(id: string, cwd: string | null = '/workspace'): SessionHeader { @@ -287,14 +289,6 @@ describe('ApiSession Agent lookup and recovery', () => { }) describe('ApiSession model selection', () => { - it('requires the model-selection projection', async () => { - const { ctx, agents } = await harness() - const live = agent(ctx, header('missing-model-projection')) - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - - expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection') - }) - it('reads a reasoning-free request and consumes only the exact pending selection', async () => { const { ctx, agents } = await harness() const logged = agent(ctx, header('logged-model')) diff --git a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts index 79bad41b7c..d7ab911e57 100644 --- a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts +++ b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -16,6 +17,7 @@ interface Harness { async function harness(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const archived: SessionId[] = [] ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never) @@ -107,6 +109,7 @@ describe('Archived-session gate: lifetime', () => { it('stops rejecting once the plugin fiber is disposed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const archived: SessionId[] = [] ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index f43ce3f116..eb2093ea18 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -47,6 +48,7 @@ async function baseContext(): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) ctx.provide('agentDefaultModel', { diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 5a4d946bf0..246d512896 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -1,6 +1,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -36,9 +37,10 @@ async function commandHarness( }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const sessionId = SessionId('commands-session') const ancestor = Session.create(SessionId('ancestor')) ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({ @@ -126,6 +128,7 @@ describe('Session queue commands', () => { it('preserves the cold Agent resolver rejection', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const error = new RemoteError('session/agent-busy', 'owned by a child', { reason: 'subagent-owned' }) const controller = new SessionCommandController(ctx, { diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 223a2f6c52..51d1ade93d 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,6 +1,7 @@ -import { Context } from '@deepseek-ai/cordis' +import { Context, FiberState } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentController from '@deepseek-ai/dsh-agent-controller' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' @@ -36,6 +37,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentController) await ctx.plugin(CommandRuntime) @@ -244,26 +246,39 @@ describe('Session file uploads', () => { await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' }) }) - it('resolves a cold ordinary Agent and releases the resolver registration', async () => { + it('resolves a cold ordinary Agent and releases the resolver with its registering fiber', async () => { const { ctx, uploads, agent, disposeAgent } = await uploadHarness() await disposeAgent() const resolveAgent = vi.fn(async () => { await ctx.agents.register(agent) return agent }) - const disposeResolver = uploads.registerAgentResolver(resolveAgent) - expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered') + const resolverOwner = { + name: 'resolver-owner', + inject: ['fileUploads'], + apply: (owner: Context) => { + owner.effect(() => owner.fileUploads.registerAgentResolver(resolveAgent), 'test: Agent resolver') + }, + } + const first = ctx.plugin(resolverOwner) + await first + expect(() => { ctx.fileUploads.registerAgentResolver(resolveAgent) }).toThrow('already registered') await expect(uploads.uploadStream({ sessionId: SESSION, data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), })).resolves.toMatchObject({ file: { bytes: 1 } }) expect(resolveAgent).toHaveBeenCalledWith(SESSION) + await first.dispose() + const second = ctx.plugin(resolverOwner) + await second + expect(second.state).toBe(FiberState.ACTIVE) + await second.dispose() + const disposeResolver = ctx.fileUploads.registerAgentResolver(resolveAgent) disposeResolver() const replacement = vi.fn(async () => agent) - const disposeReplacement = uploads.registerAgentResolver(replacement) + const disposeReplacement = ctx.fileUploads.registerAgentResolver(replacement) disposeResolver() - expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered') - expect(disposeReplacement).toBeTypeOf('function') + expect(() => { ctx.fileUploads.registerAgentResolver(replacement) }).toThrow('already registered') disposeReplacement() }) @@ -327,13 +342,10 @@ describe('Session file uploads', () => { new AbortController().signal, ) await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE, SIGNAL) - first.ctx.emit('session/event', first.agent.session, { - type: 'user/message', - data: createUserMessage({ - content: [{ type: 'text', text: 'extension event' }], - source: { kind: 'user', rpcId: 1 } as never, - }), - } as never) + first.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'extension event' }], + source: { kind: 'user', rpcId: 1 } as never, + }), { surfaceOp: 'append' }) expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file) first.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'observed' }], diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 43d1394851..cbee7aa031 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -23,6 +24,7 @@ describe('SessionController facade', () => { it('owns Host service methods and publishes Agent lifecycle projections', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = SessionId('controller-session') const header: SessionHeader = { @@ -52,7 +54,7 @@ describe('SessionController facade', () => { bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const status = vi.fn() const failure = vi.fn() const activity = vi.fn() @@ -144,6 +146,7 @@ describe('SessionController facade', () => { async (outcome) => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = SessionId(`background-${outcome}`) const header: SessionHeader = { @@ -157,7 +160,7 @@ describe('SessionController facade', () => { events: [], }), }) as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents const apiError = vi.fn() ctx.on('api-session/error', apiError) @@ -200,6 +203,7 @@ describe('SessionController facade', () => { it('waits for an admitted background promotion during teardown', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = SessionId('background-disposal') const header: SessionHeader = { @@ -213,7 +217,7 @@ describe('SessionController facade', () => { events: [], }), }) as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents const started = Promise.withResolvers() const release = Promise.withResolvers() diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index b3e412ec9f..d589dfd5ca 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -8,8 +8,8 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' -import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' +import { createSessionTestController, TEST_IMAGE_LIMITS } from './test-remote.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionList } from '../src/list.ts' @@ -48,7 +48,9 @@ async function harness(rows: readonly Row[], summaryMs = 8, workSliceMs = 8) { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) + ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never) const list = new ApiSessionList(ctx, workSliceMs) + await list.initialize() const clock = { now: 0 } const prepared = new Map() const agents = new Map() @@ -190,8 +192,10 @@ describe('Session-list work slices', () => { h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never) h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) - new RemoteAccessService(h.ctx) - const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false }) + const controller = await createSessionTestController(h.ctx, { + nativeOpen: false, listWorkSliceMs: 4, canOpenPath: () => false, + defaultModelSelection: () => ({ provider: 'test', model: 'test' }), cwd: '/tmp', + }) const yieldWork = vi.spyOn(scheduler, 'yield') const result = await controller.list({}, new AbortController().signal) expect(result.items).toHaveLength(3) diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index fcfe1a4820..150b82d415 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -47,10 +47,10 @@ async function harness(withPersistence = true) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.plugin(AgentRegistry) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(titleProjectionDefinition) if (withPersistence) { await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' }) const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing')) @@ -58,7 +58,7 @@ async function harness(withPersistence = true) { expect(list).not.toHaveBeenCalled() }) } - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test' }), cwd: root, }) diff --git a/packages/api/session-controller/tests/prompt-admission.host.spec.ts b/packages/api/session-controller/tests/prompt-admission.host.spec.ts index 0fc6a8a71d..fc2c6ffeb4 100644 --- a/packages/api/session-controller/tests/prompt-admission.host.spec.ts +++ b/packages/api/session-controller/tests/prompt-admission.host.spec.ts @@ -11,6 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore from '@deepseek-ai/dsh-session' import type { UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { createSessionTestRemote } from './test-remote.ts' @@ -20,6 +21,7 @@ async function harness(options: { readonly refusal?: Error } = {}) { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create() const inbox = createInboxStub() @@ -43,7 +45,7 @@ async function harness(options: { readonly refusal?: Error } = {}) { whenIdle: () => Promise.resolve(), } const unregister = await ctx.agents.register(agent) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), cwd: '/tmp', }) diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 617c0c6706..42b78e5190 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -88,7 +88,7 @@ async function harness(): Promise<{ const loop = await mountAgentLoopTestHarness(ctx) const shared = await loop.create(SHARED) const hidden = await loop.create(PRIVATE) - const controller = createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const as = (principal: Principal, method: string): Context => { const invocation: RemoteInvocation = { request: { namespace: 'session', method, args: {} }, diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index a6b7a033dd..1544d49c4c 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -81,6 +82,7 @@ describe('sessions.list cold merge', () => { const ctx = new Context() try { await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const historical = header('format-historical', 100) const current = header('format-current', 200) @@ -97,7 +99,7 @@ describe('sessions.list cold merge', () => { const stat = vi.fn() const open = vi.fn() ctx.provide('sessionPersistence', { list, stat, open } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -147,7 +149,7 @@ describe('sessions.list cold merge', () => { ? { asOfSeq: 2, values: { title: 'Cached predecessor title' } } : undefined, } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -209,7 +211,7 @@ describe('sessions.list cold merge', () => { }, cachedPredecessorTitle: () => undefined, } as never) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.list(request({})) expect(response.ok).toBe(true) @@ -246,8 +248,9 @@ describe('attached updatedAt tracks human prompts', () => { it('ignores pickup and non-prompt work after the latest human message', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await new Promise(resolve => setTimeout(resolve, 0)) // Old work, resumed just now: the log tail would report the pickup. @@ -306,7 +309,7 @@ describe('cold history recovery view', () => { list: () => Promise.resolve([structuredClone(meta)]), inspect: () => Promise.resolve({ meta: structuredClone(meta), events: structuredClone(events) }), }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const history = await remote.page({ address: { kind: 'session', sessionId }, @@ -365,7 +368,7 @@ describe('Remote Agent and Session lookup policy', () => { }), }) const resume = vi.spyOn(ctx.agents, 'resume') - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -390,8 +393,9 @@ describe('Remote Agent and Session lookup policy', () => { it('keeps queue-item-not-found for a cold session when no persistence backend is composed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -410,6 +414,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-remote-cold') const meta = header(sessionId, 1000) @@ -427,7 +432,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -450,6 +455,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const coldId = sid('session-remote-cold-child') const coldMeta = header(coldId, 1000, { @@ -469,7 +475,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -494,6 +500,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-remote-resumed-child') const meta = header(sessionId, 1000) @@ -511,7 +518,7 @@ describe('Remote Agent and Session lookup policy', () => { return { agent: published, dispose: () => Promise.resolve() } }) const defaultLookup = ctx.typert.lookups.get('agent') - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -529,6 +536,7 @@ describe('subagent ownership fence', () => { it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-child') const meta = header('session-child', 1000, { @@ -562,8 +570,8 @@ describe('subagent ownership fence', () => { inspect, }) const resume = vi.spyOn(ctx.agents, 'resume') - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const history = await new SessionHistoryController( ctx, @@ -607,6 +615,7 @@ describe('subagent ownership fence', () => { it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-legacy-child') const meta = header('session-legacy-child', 1000, { @@ -629,7 +638,7 @@ describe('subagent ownership fence', () => { // answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const prompt = await remote.prompt(promptRequest({ sessionId, @@ -644,6 +653,7 @@ describe('subagent ownership fence', () => { it('rejects origin-marked and runtime-owned live children from generic controls', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } }) const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent @@ -669,7 +679,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const stopped = await remote.cancel(request({ sessionId: originChild.id })) expect(stopped.ok).toBe(false) @@ -703,6 +713,7 @@ describe('subagent ownership fence', () => { it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-ordinary-fork'), { seed: [{ @@ -719,7 +730,7 @@ describe('subagent ownership fence', () => { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent await ctx.agents.register(agent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.prompt(promptRequest({ sessionId: agent.id, @@ -733,6 +744,7 @@ describe('subagent ownership fence', () => { it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() @@ -740,7 +752,7 @@ describe('subagent ownership fence', () => { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent await ctx.agents.register(agent) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -804,8 +816,9 @@ describe('degenerate composition (no persistence, no factory)', () => { it('lists no cold rows and reports an absent point source as not found', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const listed = await remote.list(request({})) expect(listed.ok).toBe(true) @@ -825,6 +838,7 @@ describe('degenerate composition (no persistence, no factory)', () => { it('maps a missing direct persistence read to session-not-found', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const inspect = vi.fn() const stat = vi.fn(() => Promise.resolve(undefined)) @@ -833,7 +847,7 @@ describe('degenerate composition (no persistence, no factory)', () => { stat, inspect, }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.page({ address: { kind: 'session', sessionId: sid('session-missing') }, @@ -851,6 +865,7 @@ describe('sessions.prompt synchronous rejection', () => { it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-empty-prompt')) const followup = vi.fn() @@ -883,7 +898,7 @@ describe('sessions.prompt synchronous rejection', () => { provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'], }), } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -947,6 +962,7 @@ describe('sessions.prompt synchronous rejection', () => { it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-throwing')) // A live structural stub whose delivery verbs throw synchronously, the @@ -960,7 +976,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await remote.prompt(promptRequest({ @@ -980,6 +996,7 @@ describe('sessions.prompt synchronous rejection', () => { it('classifies a raced cold-resume ID collision as agent-busy', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('race-resume') const meta: SessionHeader = header('race-resume', 1000) @@ -1001,7 +1018,7 @@ describe('sessions.prompt synchronous rejection', () => { await ctx.agents.register(child) throw new Error('session id already published') }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' })) expect(selection.ok).toBe(false) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index e6ba8321ce..417112f78e 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' @@ -32,6 +33,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) ctx.provide('workspaceRegistry', { list: () => workspaces, archivedSessionIds: [] } as never) @@ -90,7 +92,7 @@ async function liveAgent( return session } -const remote = (ctx: Context) => createSessionTestRemote(ctx, { +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', }) @@ -120,7 +122,7 @@ describe('sessions.fork', () => { }) as never) const open = vi.spyOn(ctx.sessionPersistence, 'open') - const response = await remote(ctx).fork({ + const response = await (await remote(ctx)).fork({ sessionId: source.id, ...allowMigration === undefined ? {} : { allowMigration }, }) @@ -150,7 +152,7 @@ describe('sessions.fork', () => { const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') const create = vi.spyOn(ctx.agents, 'create') - await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } }, }) expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) @@ -171,7 +173,7 @@ describe('sessions.fork', () => { const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read'))) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) - const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false }) + const result = await (await remote(ctx)).fork({ sessionId: source.id, allowMigration: false }) if (!result.ok) throw result.error expect(stat).not.toHaveBeenCalled() @@ -199,7 +201,7 @@ describe('sessions.fork', () => { return finish.promise }) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) - const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false }) + const operation = (await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false }) try { await entered.promise const live = ctx.sessions.create(sourceId, { meta: source.header }) @@ -229,7 +231,7 @@ describe('sessions.fork', () => { const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') const create = vi.spyOn(ctx.agents, 'create') - await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' }, }) @@ -262,9 +264,9 @@ describe('sessions.fork', () => { expect(compacted.at(-1)).toMatchObject({ type: 'compaction/end', data: { turn: null } }) source.inbox.append('next-turn', message('queued after compaction')) - const response = await createSessionTestRemote(ctx, { + const response = await (await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp', - }).fork({ sessionId: source.id }) + })).fork({ sessionId: source.id }) if (!response.ok) throw response.error const child = ctx.agents.get(response.value.sessionId)! @@ -304,9 +306,9 @@ describe('sessions.fork', () => { } const original = source.session.snapshotEvents() const atSeq = anchor === 'omitted' ? undefined : boundary - const response = await createSessionTestRemote(ctx, { + const response = await (await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp', - }).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) }) + })).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) }) if (!response.ok) throw response.error const child = ctx.agents.get(response.value.sessionId)! const requestCount = adapter.requests.length @@ -327,7 +329,7 @@ describe('sessions.fork', () => { it('cuts exactly at a completed turn and records lineage and cwd', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-source', 2) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 2 })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: 2 })) expect(response.ok ? null : response.error).toBeNull() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -369,7 +371,7 @@ describe('sessions.fork', () => { root: { header: owner.header, live: true, persisted: false }, }) - const response = await remote(ctx).fork(request({ sessionId: grandchild.id })) + const response = await (await remote(ctx)).fork(request({ sessionId: grandchild.id })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -424,7 +426,7 @@ describe('sessions.fork', () => { }) as never) const resume = vi.spyOn(ctx.agents, 'resume') - const response = await remote(ctx).fork(request({ sessionId: sourceId })) + const response = await (await remote(ctx)).fork(request({ sessionId: sourceId })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -441,7 +443,7 @@ describe('sessions.fork', () => { it('uses the latest completed prefix only when atSeq is omitted', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-tail', 2, 'open') - const proxy = remote(ctx) + const proxy = await remote(ctx) const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { @@ -464,7 +466,7 @@ describe('sessions.fork', () => { it('rejects an omitted boundary when no turn has completed', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-never-completed', 0, 'open') - const response = await remote(ctx).fork(request({ sessionId: source.id })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id })) expect(response).toMatchObject({ ok: false, error: { code: 'session/fork-unavailable', details: { sessionId: source.id } }, @@ -481,7 +483,7 @@ describe('sessions.fork', () => { const inherited = source.snapshotEvents() if (openNextTurn) source.append('turn/start', { turn: 2 }) - const response = await remote(ctx).fork({ sessionId: source.id }) + const response = await (await remote(ctx)).fork({ sessionId: source.id }) if (!response.ok) throw response.error const child = ctx.sessions.get(response.value.sessionId)! @@ -496,7 +498,7 @@ describe('sessions.fork', () => { it('rejects invalid fork anchors before reading or creating a Session', async () => { const ctx = await composed() - const proxy = remote(ctx) + const proxy = await remote(ctx) for (const atSeq of [-1, 0.5]) { await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq }))) @@ -510,7 +512,7 @@ describe('sessions.fork', () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted') const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1 - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -527,7 +529,7 @@ describe('sessions.fork', () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-open', 1, 'open') const boundary = source.snapshotEvents().at(-1)?.seq ?? 0 - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -548,7 +550,7 @@ describe('sessions.fork', () => { const boundary = source.snapshotEvents().at(-1)?.seq ?? 0 source.append('session/title', { title: 'after boundary', messageSeqs: [], source: { kind: 'fallback' } }) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -604,7 +606,7 @@ describe('sessions.fork', () => { }) as never) const resume = vi.spyOn(ctx.agents, 'resume') - const response = await remote(ctx).fork(request({ sessionId: sourceId, atSeq: 4 })) + const response = await (await remote(ctx)).fork(request({ sessionId: sourceId, atSeq: 4 })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -656,7 +658,7 @@ describe('sessions.fork', () => { reason: 'change', }) source.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary.seq })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.agents.get(response.value.sessionId) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index 86ed42a678..f1954ed935 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionStore from '@deepseek-ai/dsh-session' import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' @@ -50,6 +51,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session async function harness(): Promise<{ ctx: Context }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) return { ctx } @@ -719,7 +721,7 @@ describe('Session history raw journal', () => { it('serves raw call and result entries without parsing tool arguments', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const start = session.append('turn/start', { turn: 1 }) const call = session.append('tool/call', { @@ -760,7 +762,7 @@ describe('Session history raw journal', () => { ])('paginates $name in one contiguous Turn window', async ({ replies, beforeMessage, firstTurn, steering, minMessages = 50 }) => { const { ctx } = await harness() onTestFinished(() => ctx.fiber.dispose()) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const starts: SessionEvent[] = [] const messages: SessionEvent[][] = [] @@ -818,7 +820,7 @@ describe('Session history raw journal', () => { it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const first = appendUserText(session, 'first prompt') @@ -872,7 +874,7 @@ describe('Session history raw journal', () => { it('paginates a message with a large embedded stream without expanding physical records', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) @@ -910,7 +912,7 @@ describe('Session history raw journal', () => { it('keeps an earlier declared source on the same message-aligned page', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const source = session.append('request/context', { provider: 'p', model: 'm' }) const laterSource = session.append('request/context', { provider: 'p', model: 'm' }) @@ -931,7 +933,7 @@ describe('Session history raw journal', () => { it('keeps compact reasoning and tool-call runs nested in one attempt event', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const callId = ToolCallId('packed-call') const attempt = session.append('assistant/attempt', { diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts index fce236d3e3..f433d20c1b 100644 --- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -22,10 +23,11 @@ import { createSessionTestRemote, type TestSessionRemote } from './test-remote.t async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => Promise }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) return { ctx, - remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), + remote: await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), attach: async (session) => { await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 6a86663df5..f6e4600063 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import AttachmentStore from '@deepseek-ai/dsh-attachment' import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -96,6 +97,7 @@ async function harness(logged?: { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [ { provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' }, @@ -223,7 +225,7 @@ describe('Web session model selection', () => { ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -291,7 +293,7 @@ describe('Web session model selection', () => { const steer = vi.fn() const followup = vi.fn() Object.assign(agent, { steer, followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -320,7 +322,7 @@ describe('Web session model selection', () => { it('allows a text-only selection while durable or pending images remain available for later models', async () => { const { ctx, agent, sessionId } = await harness() registerTextOnly(ctx) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -361,7 +363,7 @@ describe('Web session model selection', () => { if (textOf(message) === 'held') await release.promise return await next() }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -390,7 +392,7 @@ describe('Web session model selection', () => { await releases.get(textOf(message))?.promise return await next() }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -434,7 +436,7 @@ describe('Web session model selection', () => { return listing.promise } }('Gated', [])) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -462,7 +464,7 @@ describe('Web session model selection', () => { } const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) ctx.provide('attachments', { readImage } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -538,7 +540,7 @@ describe('Web session model selection', () => { value: { providers: { custom: { apiKeyEnv: 'CUSTOM_API_KEY', models: [] } } }, }]) await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'saved' }) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) await controller.initializeDefaultModel() @@ -555,7 +557,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const catalog = expectValue(await remote.modelCatalog()) expect(currentSelection(ctx, sessionId)).toEqual({ @@ -604,7 +606,7 @@ describe('Web session model selection', () => { return Promise.reject('string catalog failure') } }('String Failure', [])) - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -632,7 +634,7 @@ describe('Web session model selection', () => { it('rejects unlisted models and switches available models only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -711,7 +713,7 @@ describe('Web session model selection', () => { it('reads the Agent default live for a session whose log names no selection', async () => { const { ctx, sessionId } = await harness() let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) @@ -733,7 +735,7 @@ describe('Web session model selection', () => { model: 'deepseek-chat', }) let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) @@ -751,12 +753,14 @@ describe('Web session model selection', () => { reasoningEffort: ReasoningEffortId('high'), adapterDefaults: { reasoningEffort: true }, }) - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }), cwd: '/tmp', }) - expect(new ApiSessionAgentController(ctx).selectionFor(agent).current) + const controller = new ApiSessionAgentController(ctx) + await controller.initialize() + expect(controller.selectionFor(agent).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) await ctx.fiber.dispose() }) @@ -771,7 +775,7 @@ describe('Web session model selection', () => { await Promise.allSettled([...operations, ...saves]) await ctx.fiber.dispose() }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), saveDefaultModelSelection: () => { saves.push(release.promise) @@ -797,7 +801,7 @@ describe('Web session model selection', () => { onTestFinished(() => { warn.mockRestore() }) const saved: unknown[] = [] let reject = false - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), saveDefaultModelSelection: (selection) => { saved.push(selection) @@ -836,7 +840,7 @@ describe('Web session model selection', () => { const { ctx, sessionId, agent } = await harness() const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const list = vi.spyOn(ctx.llm, 'listModels') @@ -860,7 +864,7 @@ describe('Web session model selection', () => { }()) try { await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp', }) expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' })) @@ -874,7 +878,7 @@ describe('Web session model selection', () => { const { ctx, sessionId, agent } = await harness() const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', }) @@ -896,7 +900,7 @@ describe('Web session model selection', () => { it('admits a removed model without changing its saved selection', async () => { const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const events = [...agent.session.snapshotEvents()] @@ -912,7 +916,7 @@ describe('Web session model selection', () => { it('serves a session and its catalog when the stored default names a route that is gone', async () => { const { ctx, sessionId } = await harness() - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { // What a Models-page removal leaves behind: the settings document still // names the route the user last picked, and nothing serves it. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), @@ -960,7 +964,7 @@ describe('Web session model selection', () => { }, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -1031,7 +1035,7 @@ it('initializes the account model without reasoning metadata and rejects an empt const dispose = ctx.llm.registerAdapter(['deepseek-account'], new CatalogAdapter('Account', [ { provider: 'deepseek-account', id: 'basic', name: 'Basic' }, ])) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) await controller.initializeDefaultModel() diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index e8216b5214..157d6202bb 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path' import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionStore from '@deepseek-ai/dsh-session' import FsLocal from '@deepseek-ai/dsh-fs-local' import { onTestFinished } from 'vitest' @@ -14,6 +15,7 @@ import { async function context(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(FsLocal) onTestFinished(() => ctx.fiber.dispose()) @@ -23,7 +25,7 @@ async function context(): Promise { describe('session/openWorkspacePath', () => { it('reports the deployment opener capability independently of a Session', async () => { const ctx = await context() - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', canOpenPath: () => false, @@ -33,21 +35,21 @@ describe('session/openWorkspacePath', () => { }) it('derives opener availability from config, an injected opener, or the platform probe', async () => { - const configured = createSessionTestRemote(await context(), { + const configured = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, }) await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) - const injected = createSessionTestRemote(await context(), { + const injected = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath: () => Promise.resolve(), }) await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true }) - const detected = createSessionTestRemote(await context(), { + const detected = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', }) @@ -57,7 +59,7 @@ describe('session/openWorkspacePath', () => { it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -73,7 +75,7 @@ describe('session/openWorkspacePath', () => { it('normalizes relative and absolute Host-resolvable paths', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -87,7 +89,7 @@ describe('session/openWorkspacePath', () => { it('rejects empty paths before opening anything', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -102,7 +104,7 @@ describe('session/openWorkspacePath', () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text'))) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -129,7 +131,7 @@ describe('session/openWorkspacePath', () => { throw new Error('opening stopped') }) .mockRejectedValueOnce('desktop unavailable') - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -150,7 +152,7 @@ it('reports Host file-manager metadata and dispatches reveal separately from def const ctx = await context() const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) try { @@ -167,7 +169,7 @@ it('uses the native reveal adapter without a test override and respects unsuppor const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) try { - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, }) expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) @@ -183,7 +185,7 @@ it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before nat onTestFinished(() => { mapping.mockRestore() }) const openPath = vi.fn(async () => {}) const revealPath = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal)) @@ -197,7 +199,7 @@ it('rejects a filesystem mapping that resolves to another process path', async ( const mapping = vi.spyOn(ctx.fs, 'processPath').mockReturnValue('/different/report.html') onTestFinished(() => { mapping.mockRestore() }) const openPath = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, }) await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal)) @@ -209,7 +211,7 @@ it('dispatches default-app opening to the association adapter', async () => { const ctx = await context() const open = vi.spyOn(nativeCommand, 'openNativeAssociatedPath').mockResolvedValue(undefined) onTestFinished(() => { open.mockRestore() }) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, }) const signal = new AbortController().signal @@ -223,7 +225,7 @@ it('uses the same path verification for handler queries and explicit application const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] const fileApplications = vi.fn(async () => applications) const openFileApplication = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, openFileApplication, }) @@ -242,11 +244,11 @@ it('uses the same path verification for handler queries and explicit application it('avoids desktop queries when unavailable and rejects an empty query path', async () => { const fileApplications = vi.fn(async () => []) - const unavailable = createSessionTestController(await context(), { + const unavailable = await createSessionTestController(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications, }) expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([]) - const available = createSessionTestController(await context(), { + const available = await createSessionTestController(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, }) await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) @@ -262,7 +264,7 @@ it('returns bounded query failures and classifies cancellation through the Remot lifetime.abort() throw new Error('native query stopped') }) - const remote = createSessionTestRemote(await context(), { + const remote = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, }) await expect(remote.workspacePathApplications({ path: '/file.mp3' })).resolves.toMatchObject({ diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 0fc3132fe7..2308ef77c0 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -5,8 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' @@ -64,7 +64,9 @@ async function harness(options: HarnessOptions = {}) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) + const preset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) const presets = options.presets const selections: { sessionId: SessionId; agentPreset: string }[] = [] const resumed: SessionId[] = [] @@ -87,6 +89,7 @@ async function harness(options: HarnessOptions = {}) { } ctx.provide('agentPresets', { defaultId: presets[0], + presetForSession: (session: Session) => preset.stateOf(session), resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id)) .then(() => ({ id: known(id) })), mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })), @@ -161,15 +164,13 @@ async function harness(options: HarnessOptions = {}) { }, } ctx.agents.setFactory(factory) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) const presetOf = (id: SessionId): string | null | undefined => { const session = ctx.sessions.get(id) - return session === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'agentPreset') + return session === undefined ? undefined : preset.stateOf(session) } const selectedEvents = (id: SessionId): unknown[] => { const session = ctx.sessions.get(id) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 1c216cbcd5..db37e8bcb8 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -41,11 +41,6 @@ afterEach(async () => { let nextHarnessSession = 1 declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'test/last-user': LastUserState - 'test/internal-count': number - 'test/private-prompt': string | null - } interface SessionProjectionMap { 'test/last-user': { text: string } | null } @@ -160,14 +155,14 @@ function seedMessages(session: Session, count: number): void { } } -const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('session.history projections block', () => { it('keeps the v0 numeric seed cut on the wire while logical headers expose only lineage', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const parent = ctx.sessions.create(SessionId('wire-seed-parent'), { meta: { cwd: '/workspace' } }) parent.append('turn/start', { turn: 1 }) parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -182,7 +177,7 @@ describe('session.history projections block', () => { }, }) - const snapshot = await opening(remote(ctx), child.id) + const snapshot = await opening(await remote(ctx), child.id) expect(snapshot.header).toEqual({ version: SESSION_FORMAT_VERSION, @@ -197,7 +192,7 @@ describe('session.history projections block', () => { it('tracks pending and used model selections across repeated request headers', async () => { const { ctx, session } = await harness(true) - remote(ctx) + await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const selected = { provider: 'p', model: 'next' } session.append('model/selection', selected) @@ -225,9 +220,9 @@ describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 3) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) const { records, projections } = snapshot expect(projections.asOfSeq).toBe(session.seq - 1) expect(projections.values['test/last-user']).toEqual({ text: 'm2' }) @@ -257,7 +252,7 @@ describe('session.history projections block', () => { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events, inheritedEventCount: SessionLogOffset(0) }), }) as never) - const snapshot = await opening(remote(ctx), coldId) + const snapshot = await opening(await remote(ctx), coldId) expect(snapshot.projections.values.inbox).toEqual({ 'next-turn': [message], @@ -269,7 +264,7 @@ describe('session.history projections block', () => { it('removes claimed steering from the pending Inbox projection immediately', async () => { const { ctx, session, claim } = await harness(true) - const proxy = remote(ctx) + const proxy = await remote(ctx) const message = createUserMessage({ content: [{ type: 'text', text: 'apply this now' }], source: { kind: 'user' }, @@ -309,10 +304,10 @@ describe('session.history projections block', () => { it('returns a complete current replacement cut on each follow generation', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 2) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) expect(snapshot.records.map(record => record.event.seq)).toEqual([0, 1]) expect(snapshot.projections.asOfSeq).toBe(1) @@ -323,9 +318,9 @@ describe('session.history projections block', () => { it('projects an empty log at cursor -1', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) expect(snapshot.records).toEqual([]) expect(snapshot.projections.asOfSeq).toBe(-1) @@ -350,7 +345,7 @@ describe('session.history projections block', () => { saveImage(): Promise { return Promise.reject(new Error('unused')) } readImage(): Promise { return Promise.reject(new Error('unused')) } }) - const gateway = remote(ctx) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) seedMessages(session, 2) const snapshot = await opening(gateway, session.id) @@ -377,18 +372,18 @@ describe('session.history projections block', () => { await expect(extra).resolves.toEqual({ done: true, value: undefined }) }) - it('leaves the imageLimits key absent while no attachment service is composed', async () => { + it("publishes image limits from the Controller's attachment dependency", async () => { const { ctx, session } = await harness(true) seedMessages(session, 1) - const snapshot = await opening(remote(ctx), session.id) - expect('imageLimits' in snapshot.projections.values).toBe(false) + const snapshot = await opening(await remote(ctx), session.id) + expect(snapshot.projections.values.imageLimits).toEqual(ctx.attachments.imageLimits) }) it('never carries the block on loadOlder pages (beforeSeq present)', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 5) - const older = await page(remote(ctx), request({ + const older = await page(await remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1, beforeSeq: 3, maxMessages: 2, })) expect(older.ok).toBe(true) @@ -399,7 +394,7 @@ describe('session.history projections block', () => { it('serves no block when the composition has no projection registry', async () => { const { ctx, session } = await harness(false) seedMessages(session, 2) - const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) + const response = await page(await remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) expect(response.ok).toBe(true) if (!response.ok) throw new Error('unreachable') expect('projections' in response.value).toBe(false) @@ -407,8 +402,8 @@ describe('session.history projections block', () => { it('never exposes a host-only unit through history, listing, or push frames', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(internalCountUnit()) - const proxy = remote(ctx) + await ctx.sessionProjections.register(internalCountUnit()) + const proxy = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() const iterator = proxy.control(abort.signal)[Symbol.asyncIterator]() @@ -438,13 +433,13 @@ describe('session.history projections block', () => { it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => { const { ctx, session } = await harness(true) - const dispose = ctx.sessionProjections.register(lastUserUnit()) + const dispose = await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 1) - const proxy = remote(ctx) + const proxy = await remote(ctx) const before = await opening(proxy, session.id) expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' }) - dispose() + await dispose.dispose() const after = await opening(proxy, session.id) // The registry stays mounted; only the disposed key leaves while the // gateway-owned Session-list unit remains. @@ -459,8 +454,8 @@ describe('session.history projections block', () => { it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => { const { ctx, session } = await harness(true) expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false) - const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => { - createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const fiber = ctx.plugin(Object.assign(async (gatewayCtx: Context) => { + await createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'sessionProjections'] })) await fiber.await() await vi.waitFor(() => { @@ -475,8 +470,8 @@ describe('session.history projections block', () => { describe('session.list projections column', () => { it('serves every already-materialized wire value from the live registry without folding', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) - const gateway = remote(ctx) + await ctx.sessionProjections.register(lastUserUnit()) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) session.append('turn/start', { turn: 1 }) seedMessages(session, 1) @@ -496,8 +491,8 @@ describe('session.list projections column', () => { const session = ctx.sessions.create(SessionId('preset-list'), { meta: { cwd: '/workspace', agentPreset: 'standard' }, }) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - const gateway = remote(ctx) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) session.append('agent-preset/selected', { agentPreset: 'minimal' }) @@ -512,9 +507,9 @@ describe('session.list projections column', () => { seedMessages(session, 1) const unit = lastUserUnit() const apply = vi.fn(unit.apply) - ctx.sessionProjections.register({ ...unit, apply }) + await ctx.sessionProjections.register({ ...unit, apply }) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -525,7 +520,7 @@ describe('session.list projections column', () => { it('omits the column entirely when no registry is mounted', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -558,7 +553,7 @@ describe('session.list projections column', () => { : undefined }, } as never) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) @@ -583,11 +578,11 @@ describe('session.list projections column', () => { await ctx.plugin(StorageJson, { root }) await ctx.plugin(StorageDomain, { backend: 'json' }) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(privatePromptUnit()) + await ctx.plugin(AgentRegistry) + await ctx.sessionProjections.register(privatePromptUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) - const gateway = remote(ctx) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const id = SessionId('session-cold-host-state') @@ -638,9 +633,9 @@ describe('session.list projections column', () => { await ctx.plugin(StorageJson, { root }) await ctx.plugin(StorageDomain, { backend: 'json' }) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.plugin(AgentRegistry) + await ctx.sessionProjections.register(titleProjectionDefinition) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return ctx } @@ -652,7 +647,7 @@ describe('session.list projections column', () => { let lastSeq: number | undefined let promptTime: number | undefined try { - remote(first) + await remote(first) await new Promise(resolve => setTimeout(resolve, 0)) let child: Session | undefined const owner = await first.plugin(Object.assign((sessionCtx: Context) => { @@ -684,7 +679,7 @@ describe('session.list projections column', () => { // record, and nothing opens the log. const second = await boot() try { - const gateway = remote(second) + const gateway = await remote(second) await new Promise(resolve => setTimeout(resolve, 0)) const load = () => { throw new Error('a cold fork listing must not load the event log') } second.provide('sessionPersistence', testSessionPersistence(second, { @@ -717,7 +712,7 @@ describe('session.list projections column', () => { ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: async () => [{ version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 5, isSeeded: false, cwd: '/tmp' }], }) as never) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) expect(row).toBeDefined() @@ -726,7 +721,7 @@ describe('session.list projections column', () => { it('a throwing column read degrades that row, never the listing', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ ...lastUserUnit(), wire: { viewSchema: z.union([z.object({ text: z.string() }), z.null()]), @@ -734,7 +729,7 @@ describe('session.list projections column', () => { }, }) seedMessages(session, 1) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -759,8 +754,8 @@ describe('Session control projection frames', () => { it('broadcasts changed view references with the causing seq and skips same-reference applies', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) - const proxy = remote(ctx) + await ctx.sessionProjections.register(lastUserUnit()) + const proxy = await remote(ctx) // The controller's onChanged subscription lives in an inject child whose // fiber activates asynchronously; yield until it lands before appending. await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index 0e4754d8b0..c9f9502331 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -65,14 +65,14 @@ async function liveAgent(ctx: Context, id: string, turns: number): Promise createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-rename', 1) - const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' })) + const renamed = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' new name ' })) expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') @@ -86,7 +86,7 @@ describe('sessions.rename', () => { const source = await liveAgent(ctx, 'session-rename-bad', 1) // U+200B passes a client-side trim gate but normalizes to empty host-side. - const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' })) + const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' ​ ' })) expect(response.ok).toBe(false) if (!response.ok) { expect(response.error).toMatchObject({ @@ -107,7 +107,7 @@ describe('sessions.rename', () => { const stale = await liveAgent(foreign, 'session-rename-stale', 1) await ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) - const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) + const response = await (await remote(ctx)).rename(request({ sessionId: stale.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) expect(response.error.code).toBe('gateway/internal') }) @@ -116,7 +116,7 @@ describe('sessions.rename', () => { const ctx = await composed(false) const source = await liveAgent(ctx, 'session-no-titles', 1) - const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) + const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) { expect(response.error.code).toBe('gateway/internal') diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index d8690ed0a0..9b14011fbe 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -17,7 +17,7 @@ import { type SessionSearchHit, type SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' -import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' +import { createSessionTestRemote, testSessionPersistence, TEST_IMAGE_LIMITS } from './test-remote.ts' import { ApiSessionList } from '../src/list.ts' const sid = (value: string): SessionId => value as SessionId @@ -57,8 +57,8 @@ function hit(id: string, index = 0): SessionSearchHit { async function baseContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) return ctx } @@ -96,7 +96,9 @@ function installSearchQuery( describe('session.search', () => { it('rejects search when the query service is absent', async () => { const ctx = await baseContext() + ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never) const list = new ApiSessionList(ctx, 8) + await list.initialize() await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/internal', @@ -151,7 +153,7 @@ describe('session.search', () => { ], })) installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const signal = new AbortController().signal const response = await remote.search(request(' matching answer '), signal) @@ -187,7 +189,7 @@ describe('session.search', () => { ctx.sessions.create(sid('visible'), { meta: header('visible') }) const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { await expect(remote.search(request(query), new AbortController().signal)) @@ -201,7 +203,7 @@ describe('session.search', () => { const ctx = await baseContext() const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const response = await remote.search( request('anything'), @@ -235,7 +237,7 @@ describe('session.search', () => { ], })) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('match'), new AbortController().signal, ) @@ -262,7 +264,7 @@ describe('session.search', () => { }) .mockResolvedValueOnce({ items: items.slice(19) }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('match'), new AbortController().signal, ) @@ -303,7 +305,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('adaptive-page-limit'), new AbortController().signal, ) @@ -346,7 +348,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('endless-pages'), new AbortController().signal, ) @@ -411,7 +413,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('stale-restart'), new AbortController().signal, ) @@ -450,7 +452,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('stale-churn'), new AbortController().signal, ) @@ -479,7 +481,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('abort-stale'), controller.signal, ) @@ -500,7 +502,7 @@ describe('session.search', () => { ))) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('first-page-stale'), new AbortController().signal, ) @@ -524,7 +526,7 @@ describe('session.search', () => { )) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('continuation-invalid-limit'), new AbortController().signal, ) @@ -551,7 +553,7 @@ describe('session.search', () => { )) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('minimum-page-limit'), new AbortController().signal, ) @@ -577,7 +579,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('abort-invalid-limit'), controller.signal, ) @@ -596,7 +598,7 @@ describe('session.search', () => { const searchSessions = vi.fn(() => Promise.resolve({ items: oversized })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('oversized-page'), new AbortController().signal, ) @@ -622,7 +624,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('adapted-oversized-page'), new AbortController().signal, ) @@ -648,7 +650,7 @@ describe('session.search', () => { } installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] })) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('bounded-snippet'), new AbortController().signal, ) @@ -670,7 +672,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('repeated-cursor'), new AbortController().signal, ) @@ -693,7 +695,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('repeated-lookahead-cursor'), new AbortController().signal, ) @@ -720,7 +722,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20) }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('duplicate-pages'), new AbortController().signal, ) @@ -748,7 +750,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('cancel-continuation'), controller.signal, ) @@ -777,7 +779,7 @@ describe('session.search', () => { })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('large corpus'), new AbortController().signal, ) @@ -813,7 +815,7 @@ describe('session.search', () => { const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('cancel-during-visibility'), controller.signal, ) @@ -840,7 +842,7 @@ describe('session.search', () => { const searchSessions = vi.fn(() => Promise.resolve({ items: [] })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('header-only-visibility'), new AbortController().signal, ) @@ -856,7 +858,7 @@ describe('session.search', () => { it('maps preflight cancellation, query cancellation, and provider failure', async () => { const missingCtx = await baseContext() missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) - const missingApi = createSessionTestRemote(missingCtx, defaults) + const missingApi = await createSessionTestRemote(missingCtx, defaults) const preAborted = new AbortController() preAborted.abort() const cancelledBeforeLookup = await missingApi.search( @@ -875,7 +877,7 @@ describe('session.search', () => { .mockRejectedValueOnce(aborted) .mockRejectedValueOnce(new Error('database unavailable')) installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const cancelled = await remote.search( request('first'), diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index 939ef7a269..5181f37c7b 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -41,6 +42,7 @@ function observation( async function context(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) provideWorkingDirectoryFixture(ctx, '/catalog/default') return ctx diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index de1852991a..9534b8b66d 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' @@ -49,6 +50,7 @@ async function bench() { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), @@ -59,7 +61,7 @@ async function bench() { installSessionReadTestServices(ctx) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) return { ctx, controller } } @@ -193,9 +195,10 @@ describe('SessionController subagent catalog', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } }) const expected = ctx.sessionProjections.snapshot(session) expect(expected.values.subagentCatalog).toBeUndefined() diff --git a/packages/api/session-controller/tests/summary-list.host.spec.ts b/packages/api/session-controller/tests/summary-list.host.spec.ts index 041d79e094..d54c2a5821 100644 --- a/packages/api/session-controller/tests/summary-list.host.spec.ts +++ b/packages/api/session-controller/tests/summary-list.host.spec.ts @@ -42,7 +42,7 @@ async function opening( sessionId: SessionId, ): Promise> { const abort = new AbortController() - const iterator = remote(ctx).follow({ address: { kind: 'session', sessionId } }, abort.signal)[Symbol.asyncIterator]() + const iterator = (await remote(ctx)).follow({ address: { kind: 'session', sessionId } }, abort.signal)[Symbol.asyncIterator]() const first = await iterator.next() abort.abort() await iterator.return?.() @@ -62,7 +62,7 @@ async function harness(): Promise<{ ctx: Context; sessionContext: Context }> { await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) ctx.provide('tools', { register: () => () => {} } as never) - ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) await ctx.plugin({ name: turnOutlineName, inject: turnOutlineInject, apply: turnOutlineApply }) await ctx.plugin({ name: sessionStatsName, inject: sessionStatsInject, apply: sessionStatsApply }) await ctx.plugin({ @@ -74,8 +74,7 @@ async function harness(): Promise<{ ctx: Context; sessionContext: Context }> { // Build the remote (and with it the list owner that registers // `sessionListMetadata`) before any Session exists, so every registered unit // seeds a cell at `session/created`. - remote(ctx) - await new Promise(resolve => setTimeout(resolve, 0)) + await remote(ctx) return { ctx, sessionContext: ctx } } @@ -100,7 +99,7 @@ describe('session.list summary payload', () => { expect([...ctx.sessionProjections.summaryKeys()]).not.toContain('turnOutline') - const response = await remote(ctx).list({}) + const response = await (await remote(ctx)).list({}) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === id) const served = Object.keys(row?.projections?.values ?? {}) @@ -134,7 +133,7 @@ describe('session.list summary payload', () => { const { id } = seedSession(ctx) // Two consecutive pulls: a refresh must not reintroduce a dropped unit. for (const attempt of [0, 1]) { - const response = await remote(ctx).list({}) + const response = await (await remote(ctx)).list({}) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === id) expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('turnOutline') diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 436905c665..53cae34ea2 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -2,7 +2,7 @@ import type { SessionControllerInternals } from '../src/index.ts' import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { Context } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import type { AdmittedPromptContentPart, @@ -105,11 +105,13 @@ export interface TestSessionRemoteDefaults { readonly openFileApplication?: SessionControllerInternals['openFileApplication'] readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean + readonly listWorkSliceMs?: number } const installed = new WeakMap() -const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ +/** Image policy for controller fixtures without attachment I/O. */ +export const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ maxImageBytes: 5 * 1024 * 1024, maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, @@ -233,10 +235,10 @@ export function installSessionReadTestServices(ctx: Context): void { if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx) } -function installControllers( +async function installControllers( ctx: Context, defaults: TestSessionRemoteDefaults, -): SessionController { +): Promise { const found = installed.get(ctx) if (found !== undefined) return found @@ -295,12 +297,18 @@ function installControllers( if (ctx.get('agentController') === undefined) new AgentController(ctx) if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) - let controller: SessionController + class TestController extends SessionController { + initialize(): Promise { + return this[Service.init]() + } + } + let controller: TestController try { - controller = new SessionController( + controller = new TestController( ctx, { ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, + ...defaults.listWorkSliceMs === undefined ? {} : { listWorkSliceMs: defaults.listWorkSliceMs }, }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, @@ -310,6 +318,7 @@ function installControllers( ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) + await controller.initialize() } finally { cwd.mockRestore() } @@ -318,11 +327,11 @@ function installControllers( } /** Build or return the production Session Controller for a direct unit harness. */ -export function createSessionTestController( +export async function createSessionTestController( ctx: Context, defaults: TestSessionRemoteDefaults, -): SessionController { - return installControllers(ctx, defaults) +): Promise { + return await installControllers(ctx, defaults) } function remoteResult( @@ -346,11 +355,11 @@ function remoteResult( } /** Build the generated Session Remote's unary result semantics without a carrier. */ -export function createSessionTestRemote( +export async function createSessionTestRemote( ctx: Context, defaults: TestSessionRemoteDefaults, -): TestSessionRemote { - const direct = createSessionTestController(ctx, defaults) +): Promise { + const direct = await createSessionTestController(ctx, defaults) return { workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult( () => direct.workspacePathApplications(request, signal), signal, diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 890000a0ac..66b697fcef 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -76,7 +76,7 @@ async function setup(): Promise<{ ctx: Context; transport: SessionHistoryControl const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) return { ctx, transport } } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index c13fb647d1..be702d4775 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -24,8 +24,8 @@ en: 4a424cd401556f20 zh: 0b34dd18e0db6cea /deepseek-ai-dsh-app-boot/use-this-package/profiles: - en: d2d520668668292c - zh: b7deeab27032a3ad + en: 0a1b5f4f40d835a7 + zh: d8f4b54160aa033b /deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration: en: 646a0ccdd7881ad1 zh: e3696acb1573dd7b diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 59c38476da..4b43180589 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -64,7 +64,7 @@ The enabled `dsh-hmr` plugin watches the profile manifest and both user patch fi Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal. -Before mounting profile rows, the `dsh` launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links. +Before mounting profile rows, the `dsh` launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links. Traversal includes installed optional dependencies and skips those absent from the target installation. `sanitizeProfile(binName, profileDir, bundles)` provides filesystem recovery without loading plugins or parsing patches. Desktop uses it for native fatal recovery. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s `cordis.patch.yml` to a unique `.bak-` sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. The timestamp is Unix time in milliseconds; collisions append an ordinal (`-1`, `-2`, …) without changing it. It returns the backup path, or `undefined` when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 73e383e04a..eee5749ba6 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -64,7 +64,7 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 -挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 runtime resolution。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。 +挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 runtime resolution。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。依赖遍历包含已安装的可选依赖,跳过目标安装中缺席的包。 `sanitizeProfile(binName, profileDir, bundles)` 提供文件恢复,无需加载插件或解析 patch。Desktop 在原生致命错误恢复中调用它。调用前必须停止 profile 并排除并发 profile 写入。它将 profile 的 `cordis.patch.yml` 重命名为带唯一 `.bak-` 后缀的同目录备份,并恢复调用方指定的 bundle 列表,保留已安装包和其他 manifest 字段。时间戳为 Unix 毫秒数;同名备份已存在时追加序号(`-1`、`-2`、……),时间戳保持不变。返回值为备份路径;patch 不存在时返回 `undefined`,缺失的 profile 不会被创建。下次启动的 profile 初始化会重新创建空 patch。home 级 patch 不变。无效 profile JSON 在修改前报错;后续错误向调用方抛出,保留已完成的修改供重试。 diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 3c7aa74762..db23fbf232 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -221,6 +221,7 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas * [admission](../../../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)). */ export const OPTIONAL_BUNDLES: readonly string[] = [ + '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle', '@deepseek-ai/dsh-experimental-terminal-bundle', @@ -374,7 +375,8 @@ function readPackageManifest(anchor: string): ProfileManifest { /** Return dependency names that may be imported by a loader-visible plugin. */ function profileDependencyNames(manifest: ProfileManifest): string[] { - return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})] + return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {})] } /** Resolve the installation packages that the runtime resolver supplies to every profile. */ diff --git a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts index 9ca8a7e0d6..bed3b6c8c1 100644 --- a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts @@ -334,3 +334,28 @@ describe('profile package metadata service', () => { expect(getEnvironmentData(key)).toBe(previous) }) }) + + +it.each(['installation', 'bundle'] as const)('exposes installed optional dependencies from %s and skips absent ones', async (scope) => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-optional-resolution-'))) + roots.push(root) + const installAnchor = join(root, 'install', 'package.json') + file(installAnchor, JSON.stringify({ name: 'installation', version: '1.0.0' })) + const profileDir = join(root, 'profiles', 'test') + initProfile(profileDir, ['optional-bundle']) + const bundleDir = join(profileDir, 'node_modules', 'optional-bundle') + const bundleManifest = { name: 'optional-bundle', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } } + file(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) + file(join(bundleDir, 'cordis.patch.yml'), '[]\n') + const anchor = scope === 'installation' ? installAnchor : join(bundleDir, 'package.json') + file(anchor, JSON.stringify({ ...JSON.parse(readFileSync(anchor, 'utf8')) as object, + optionalDependencies: { 'metadata-lib': '*', 'absent-platform': '*' }, + })) + const optionalDir = join(dirname(anchor), 'node_modules', 'metadata-lib') + pkg(optionalDir, '1.0.0') + const active = await createRuntimeResolution({ + installAnchor, home: root, profile: loadProfileDirectory('dsh', profileDir, installAnchor), + }) + expect(active.entries).toContainEqual(expect.objectContaining({ name: 'metadata-lib', packageDir: optionalDir })) + expect(active.entries.some(entry => entry.name === 'absent-platform')).toBe(false) +}) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index ad3660e390..e16fd551f3 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,14 +15,17 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 95c1b18f8432090a - zh: 213e54a1547a1da1 + en: 4a97c164c4eaf933 + zh: 8be4ac9f8b9e4d9c /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 483285658e267dd5 zh: 846bec2daac59a96 /deepseek-ai-dsh-plugin-manager/use-this-package/configuration: - en: d5643a06f300c30a - zh: 9348b6a7676a2849 + en: 64089ca8084f5e0f + zh: 8328a53cd6420a82 +/deepseek-ai-dsh-plugin-manager/use-this-package/bundle-detail-documents: + en: 99b084ff497011d2 + zh: 6c047796c1991784 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: en: 495dadf79c48c43c zh: 9884dd04a5bb49dc @@ -45,8 +48,8 @@ en: c29f60be7f3edc1a zh: d9b9388b96ef95cc /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work: - en: 6b93fd9851b79a0e - zh: d2cf1982e8cfc718 + en: eeeaa73140fa45d7 + zh: 12b5c10f0390310e /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior: en: c670eb2330b441e8 zh: 2c8223e03e1e3ef0 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index c12c12bc47..0f1c691e9f 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. -`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. +`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata and update eligibility from list results. `listBundles` marks shipped optional bundles and on-demand catalog entries as `official`, independently of package installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` only records whether the profile declares the dependency. An absent on-demand entry carries embedded localized metadata and a host-derived `installTarget` containing the running DSH version and install spec, without registry access. Profile-declared packages with unreadable files retain their declaration and expose the normal error. @@ -51,6 +51,10 @@ Published installations offer `package@`. Source Web and de Official on-demand installs call the same `installBundle` operation as other bundles. Registry targets use `saveExact: true`; development targets retain the ordinary `link:` dependency. An explicit update removes the installed package and installs the target with the current `enabled` selection; without HMR, a bundle that runs since startup cannot be removed, so the installation goes over it and reports `restart-required`. A failed or cancelled installation after the removal leaves the package uninstalled, and the reinstalled bundle joins the end of the selection. Turning Off only deselects the layer, and Remove uses the existing removal checks. Installation failure restores the profile manifest and lockfile; activation failure after installation can retain the saved dependency and selection. The existing peer-compatibility check still applies, without an additional Official-only version admission rule. +`listVersions(name, options)` reads the published versions newer than an installed bundle that `listBundles` marks `updatable`: a third-party profile dependency saved as a registry spec under its own name, not an `npm:` alias, whose manifest declares a semver version, and that carries no plugin management. One `pnpm view versions time dist-tags` asks the registry plan from `options.registry`, else the registry this process last installed the package from, else the configured one. The newest `versionListLimit` versions above the installed one are then read from the registry that answered, one `pnpm view @` each and at most `versionLookupConcurrency` at a time, because pnpm prints only the highest match of a range. The list orders them newest first with their dist-tags, publication time, prerelease flag, deprecation message, whether they declare a bundle, and an `incompatible` record when their `engines.dsh` or DSH peers reject the running version without an exemption; `omitted` counts the newer versions left out, and `attempts` names every registry asked with what each failed with. A refusal is `not-updatable`, `not-found`, `network`, or `unknown`; one version lookup that fails refuses the whole list. Official bundles follow the DSH version and are not listed. + +`updateBundle(name, version, options)` replaces an updatable bundle with an exact newer version; an older version is refused. The target is first viewed over the same plan as `listVersions`, and a version that is missing, declares no bundle, or whose `engines.dsh` or DSH peers reject the running DSH stops the update at `step: 'check'` with the profile unchanged. With HMR the update removes the installed version, so its plugins stop, then installs `name@version` with `saveExact` and the current selection from the registry that answered. When that installation fails, is cancelled, or installs but cannot start, the target is removed if it stayed and the removed version is installed again from the registry it was installed from in this process, else over the configured plan as its removal asks, and reported as `restore`; a reinstallation that also fails leaves the package uninstalled. A removal or installation that rejects, for example on a lock timeout, is reported as that step's failure. Without HMR the target is installed over the running version and the result reports `restart-required`. `plugin-manager/update-state` announces `checking`, `removing`, `installing`, and `restoring` under the caller's `requestId`; the target's installation streams its logs and accepts `cancelInstall` under the same id, while the reinstallation carries none, so a cancellation cannot stop it. + `installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale. @@ -85,7 +89,24 @@ The CLI exposes `dsh plugin --profile version-exemptions`, `allow-vers | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. | | `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. | | `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. | +| `versionListLimit` | `20` | How many of the newest versions above the installed one a version listing reads and returns. | +| `versionLookupConcurrency` | `4` | How many single-version registry lookups a version listing runs at once. | | `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. | +| `detailMaxBytes` | `1048576` | Largest bundle detail document, and largest locale file read for one, in bytes. | +| `detailImageMaxBytes` | `5242880` | Largest image served beside a bundle detail document, in bytes. | +| `detailFetchTimeoutMs` | `15000` | Deadline of one npm file CDN request for an offered bundle's detail document. | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | npm file CDNs offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. | + + +### Bundle detail documents + +`readBundleDetail(name, language, { npmFileSources? })` reads the long description that a bundle's page in **Plugins** shows, for a bundle `listBundles()` lists: the locale `meta.detail` file, otherwise the README, as steps 1 and 2 of the [document order](../../util/plugin-detail/README.md#document-order) define them. Without either, the result is `{ status: 'none' }`; the repository a bundle declares is not read. `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order; configured CDNs it omits follow, and CDNs that `detailNpmFileSources` does not list are not asked. A found document carries its `origin` (`locale` or `readme`), its `path` inside the package, and its Markdown without a leading YAML front matter block. + +An installed bundle is read from its directory, without network access. An official bundle the installation offers before it is installed is read from the npm file CDNs at its offered version; one offered as a development link has no document. Nothing is cached, so a reinstalled bundle shows its own document. + +`GET /api/plugin-manager/detail-file?bundle=&document=&path=`, with `&sources=,...` to ask those CDNs first, serves an image beside a bundle's document to authenticated owner connections. The destination is relative to the document, or to the package root when it starts with `/`. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside the package are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and `Cache-Control: no-store`; another path or an unlisted bundle answers 404. + +Failures: `plugin-manager/unknown-bundle` for a bundle `listBundles()` does not list or whose directory cannot be resolved, `plugin-manager/invalid-language`, and `plugin-manager/detail-unavailable` with `reason` `too-large`, `network`, or `invalid` for a malformed locale file or `meta.detail`, or a named file that does not exist. The image route answers them with 404, 413, 502, and 400. ----- @@ -138,6 +159,9 @@ Tool results append to the transcript. Enabling or disabling other tools can cha - Web approves the entire displayed pending group; it has no per-package selection. - pnpm verifies every lockfile tarball URL against its default registry when a command changes the lockfile. After a package was installed from another registry, installing a different package with the default registry, or removing that package after a restart, can fail with `ERR_PNPM_TARBALL_URL_MISMATCH`. +- An update saves the target as an exact version, and a reinstallation after a failed update saves the previous version exactly, even when the profile had saved a range. With HMR the reinstalled bundle joins the end of the selection, which can change which bundle's configuration takes precedence. +- An update runs its removal and its installation as two profile operations; another operation can run between them. +- A newer version whose dependencies need new build-script approval fails to update and is reinstalled at the previous version; approve its scripts through an ordinary installation after removing the package. - Package replacements require restarting the process without HMR. A package relinked outside Plugin Manager while it is still installed waits for a restart. - Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`. - The manager cannot disable its own management components, change another profile, or edit an agent preset's composition. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 17fcf8eefd..2f8a0cc3d0 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 -`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 +`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息和更新资格。 `listBundles` 将随附可选组合包和按需目录项标为 `official`,与包的安装状态独立。`availability` 表示可读取的安装随附包、profile 包,或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 仅表示 profile 是否声明该依赖。尚未安装的按需条目携带内嵌的本地化元信息,以及 Host 生成、包含当前 DSH 版本与安装 spec 的 `installTarget`,无需访问注册表。profile 已声明但文件不可读的包保留依赖声明,并展示原有错误。 @@ -51,6 +51,10 @@ kind: "package-reference" 官方按需安装调用和其他组合包相同的 `installBundle` 操作。注册表目标使用 `saveExact: true`;开发目标保留普通 `link:` 依赖。显式更新先删除已安装的包,再以当前 `enabled` 选择状态安装目标;没有 HMR 时,自启动起运行的组合包无法删除,因此安装直接覆盖它并报告 `restart-required`。删除之后安装失败或被取消时,该包保持未安装,重新安装的组合包排在选择列表末尾。关闭仅取消选择配置层,移除使用原有移除检查。安装失败恢复 profile 清单和锁文件;安装成功后激活失败可能保留已保存的依赖和选择。原有 peer 兼容性检查继续生效,不增加仅针对官方条目的版本准入规则。 +`listVersions(name, options)` 读取 `listBundles` 标为 `updatable` 的已安装组合包的新版本:以自身包名、按注册表 spec 保存(不是 `npm:` 别名)的第三方 profile 依赖,清单声明 semver 版本,且不承载插件管理。一次 `pnpm view versions time dist-tags` 按注册表计划询问,计划从 `options.registry` 开始,否则从本进程上次安装该包所用的注册表开始,否则从配置的注册表开始。随后从作答的注册表逐个读取比已安装版本新的最新 `versionListLimit` 个版本,每个版本一次 `pnpm view @`,同时最多 `versionLookupConcurrency` 个,因为 pnpm 对范围只打印最高的匹配版本。列表按从新到旧排列,带有各版本的 dist-tag、发布时间、预发布标记、弃用说明、是否声明组合包,以及在其 `engines.dsh` 或 DSH peer 拒绝当前运行版本且没有豁免时的 `incompatible` 记录;`omitted` 统计未列出的新版本数,`attempts` 列出问过的每个注册表及其失败原因。拒绝为 `not-updatable`、`not-found`、`network` 或 `unknown`;任一版本查询失败都会拒绝整个列表。官方组合包跟随 DSH 版本,不会列出。 + +`updateBundle(name, version, options)` 把可更新的组合包替换为一个更新的确切版本;更旧的版本会被拒绝。目标版本先按与 `listVersions` 相同的计划查询,版本不存在、未声明组合包,或其 `engines.dsh` 或 DSH peer 拒绝当前 DSH 时,更新停在 `step: 'check'`,profile 保持不变。有 HMR 时,更新先移除已安装版本,使其插件停止,再以 `saveExact` 和当前的选中状态从作答的注册表安装 `name@version`。该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从本进程安装被移除版本所用的注册表重新安装它,没有记录时按配置的计划安装,与移除的做法一致,结果以 `restore` 报告;重新安装也失败时,包处于未安装状态。移除或安装被拒绝(例如锁等待超时)时,报告为该步骤失败。没有 HMR 时,目标版本直接安装在运行中的版本之上,结果报告 `restart-required`。`plugin-manager/update-state` 以调用方的 `requestId` 通告 `checking`、`removing`、`installing` 与 `restoring`;目标版本的安装以同一 id 流式输出日志并接受 `cancelInstall`,重新安装不带 id,因此取消无法中止它。 + `installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。 @@ -85,7 +89,24 @@ CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 | | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 | | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 | +| `versionListLimit` | `20` | 版本列表读取并返回的、比已安装版本新的最新版本个数。 | +| `versionLookupConcurrency` | `4` | 版本列表同时运行的单版本注册表查询个数。 | | `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 | +| `detailMaxBytes` | `1048576` | 组合包详细介绍文档的最大大小,以及为读取它而读取的 locale 文件的最大大小,单位字节。 | +| `detailImageMaxBytes` | `5242880` | 与组合包详细介绍文档一同提供的图片的最大大小,单位字节。 | +| `detailFetchTimeoutMs` | `15000` | 为读取当前安装提供、尚未安装的组合包的详细介绍文档而发出的单次 npm 文件 CDN 请求的时限。 | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | 读取当前安装提供、尚未安装的组合包时按顺序使用的 npm 文件 CDN;一次读取的 `npmFileSources` 把其中列出的 CDN 移到最前。 | + + +### 组合包详细介绍文档 + +`readBundleDetail(name, language, { npmFileSources? })` 为 `listBundles()` 列出的组合包读取**插件**页中该组合包页面显示的详细介绍:locale `meta.detail` 文件,否则为 README,即[文档顺序](../../util/plugin-detail/README.zh.md#document-order)第 1 步和第 2 步的定义。两者都没有时,结果为 `{ status: 'none' }`;不读取组合包声明的仓库。`npmFileSources` 按顺序列出读取当前安装提供、尚未安装的组合包时先请求的 npm 文件 CDN;它未列出的已配置 CDN 排在其后,`detailNpmFileSources` 未列出的 CDN 不会被请求。找到的文档携带其 `origin`(`locale` 或 `readme`)、在包内的 `path`,以及去掉开头 YAML front matter 块后的 Markdown 内容。 + +已安装的组合包从其目录读取,不访问网络。当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取;以开发链接形式提供的组合包没有文档。结果不被缓存,因此重新安装的组合包显示其自身的文档。 + +`GET /api/plugin-manager/detail-file?bundle=&document=&path=`(加上 `&sources=,...` 时先请求这些 CDN)向已通过认证的 owner 连接提供组合包文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包根目录。只提供包内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和 `Cache-Control: no-store`;其他路径或未列出的组合包返回 404。 + +失败:`listBundles()` 未列出或无法解析其目录的组合包为 `plugin-manager/unknown-bundle`;无效语言为 `plugin-manager/invalid-language`;`plugin-manager/detail-unavailable` 携带 `reason`,取值为 `too-large`、`network`,或在 locale 文件或 `meta.detail` 格式错误、指定的文件不存在时为 `invalid`。图片路由对这些失败依次返回 404、413、502 和 400。 ----- @@ -138,6 +159,9 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, - Web 一次批准显示出来的整组待决定包,没有逐包选择。 - 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。 +- 更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。 +- 更新把移除与安装作为两次 profile 操作运行,其他操作可能在两者之间运行。 +- 依赖需要新的构建脚本批准的新版本会更新失败,并重新安装为原版本;移除该包后通过普通安装批准其脚本。 - 没有 HMR 时,替换包需要重启进程。在 Plugin Manager 之外重新链接仍处于安装状态的包,需要等待重启。 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index eaa8151408..333f88f939 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -64,9 +64,12 @@ "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "execa": "^10.0.1", + "semver": "^7.8.5", "yaml": "^2.9.0", "zod": "4.4.3", - "@deepseek-ai/dsh-util-values": "workspace:*" + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-plugin-detail": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -91,6 +94,7 @@ }, "devDependencies": { "chokidar": "^4.0.3", + "@types/semver": "^7.8.0", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", @@ -107,6 +111,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*" } } diff --git a/packages/boot/plugin-manager/src/bundle-detail.ts b/packages/boot/plugin-manager/src/bundle-detail.ts new file mode 100644 index 0000000000..5cd16f4e2c --- /dev/null +++ b/packages/boot/plugin-manager/src/bundle-detail.ts @@ -0,0 +1,104 @@ +/** + * Detail documents of profile bundles: an installed bundle's `meta.detail` file or README from its directory, and an + * official bundle the installation offers, before it is installed, from the npm file CDNs at its offered version. Reads + * keep no state; an image request resolves its bundle and document again. + */ +import semver from 'semver' +import { resolveBundleDir } from '@deepseek-ai/dsh-app-boot' +import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { + DetailFailure, detailImage, languageChain, localFiles, npmFileSourceOrder, npmFiles, readPackageDocument, + type DetailFiles, type NpmFileSource, +} from '@deepseek-ai/dsh-plugin-detail' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { BundleDetail, BundleInfo } from './types.ts' + +/** Limits and sources of bundle detail reads. */ +export interface BundleDetailOptions { + readonly profile: ProfileContext + readonly maxDocumentBytes: number + readonly maxImageBytes: number + readonly npmFileSources: readonly NpmFileSource[] + /** Deadline of one CDN request. */ + readonly timeoutMs: number + /** The plugin manager's disposal. */ + readonly lifetime: AbortSignal +} + +/** + * The files of a listed bundle: its directory when its files can be read, otherwise its offered npm version. + * @param bundle - the bundle as `listBundles()` reports it. + * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`. + * @param options - profile, sources, and limits. + * @returns the files, or undefined for a bundle offered at no published version. + * @throws {RemoteError} `plugin-manager/unknown-bundle` when the bundle's directory cannot be resolved. + */ +function filesOf(bundle: BundleInfo, preferred: readonly string[], options: BundleDetailOptions): DetailFiles | undefined { + if (bundle.availability !== 'missing') { + let dir: string + try { + dir = resolveBundleDir('dsh', bundle.name, options.profile.installAnchor, options.profile.dir) + } catch (error) { + throw new RemoteError('plugin-manager/unknown-bundle', (error as Error).message, { name: bundle.name }, { cause: error }) + } + // `listBundles()` reports the version of every bundle whose manifest it read; one without is read as unversioned. + return localFiles(dir, String(bundle.version)) + } + const version = bundle.installTarget?.version + if (version === undefined || semver.valid(version) !== version) return undefined + const sources = npmFileSourceOrder(options.npmFileSources, preferred) + return npmFiles(bundle.name, version, sources, { timeoutMs: options.timeoutMs, lifetime: options.lifetime }) +} + +/** Rethrow a detail failure as the Remote error clients read. */ +function remoteFailure(error: unknown): never { + if (error instanceof DetailFailure) { + throw new RemoteError('plugin-manager/detail-unavailable', error.message, { reason: error.reason }, { cause: error }) + } + throw error +} + +/** + * Read a bundle's detail document in a display language. + * @param bundle - the bundle as `listBundles()` reports it. + * @param language - a BCP 47 language id. + * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`. + * @param options - profile, sources, and limits. + * @returns the document, or `none` when the bundle has neither a `meta.detail` file nor a README. + * @throws {RemoteError} `plugin-manager/invalid-language`, `plugin-manager/unknown-bundle`, or + * `plugin-manager/detail-unavailable`. + */ +export async function readBundleDocument( + bundle: BundleInfo, language: string, preferred: readonly string[], options: BundleDetailOptions, +): Promise { + let languages: string[] + try { + languages = languageChain(language) + } catch (error) { + throw new RemoteError('plugin-manager/invalid-language', (error as Error).message, { language }, { cause: error }) + } + const files = filesOf(bundle, preferred, options) + if (files === undefined) return { status: 'none' } + const document = await readPackageDocument(files, '', languages, options.maxDocumentBytes, options.lifetime).catch(remoteFailure) + return document === undefined ? { status: 'none' } : { status: 'found', ...document } +} + +/** + * Read an image beside a bundle's detail document. + * @param bundle - the bundle as `listBundles()` reports it. + * @param document - the document's path inside the bundle, as {@link readBundleDocument} returned it. + * @param destination - the image destination as the document writes it. + * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`. + * @param options - profile, sources, and limits. + * @returns the bytes and media type, or undefined for a path outside the bundle, an unsupported type, or a missing file. + * @throws {RemoteError} `plugin-manager/unknown-bundle` or `plugin-manager/detail-unavailable`. + */ +export async function readBundleImage( + bundle: BundleInfo, document: string, destination: string, preferred: readonly string[], options: BundleDetailOptions, +): Promise<{ bytes: Uint8Array; mediaType: string } | undefined> { + const image = detailImage(document, destination) + const files = image === undefined ? undefined : filesOf(bundle, preferred, options) + if (image === undefined || files === undefined) return undefined + const bytes = await files.read(image.path, options.maxImageBytes, options.lifetime).catch(remoteFailure) + return bytes === undefined ? undefined : { bytes, mediaType: image.mediaType } +} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 6734d2872b..16bc5ee932 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -9,7 +9,13 @@ import { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import z from '@deepseek-ai/schemastery' -import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' +import semver from 'semver' +import { TypertRemoteService, Remote, RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { detailImageResponse, NPM_FILE_SOURCES } from '@deepseek-ai/dsh-plugin-detail' +import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type {} from '@deepseek-ai/dsh-client-connection' +import { readBundleDocument, readBundleImage, type BundleDetailOptions } from './bundle-detail.ts' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, @@ -29,10 +35,12 @@ import { flatten, writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, recordPendingBuilds } from './build-approval.ts' import { checkGithubConnection } from './github-connection.ts' +import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts' import type { - BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange, - PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, - PluginRegistries, PluginSpecInspection, Registry, + BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError, + PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, + PluginRegistries, PluginSpecInspection, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry, + RegistryAttempt, UpdateBundleOptions, } from './types.ts' export type * from './types.ts' export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts' @@ -60,8 +68,23 @@ export interface Config { * unless that is npm's own registry or one of these. */ fallbackRegistries?: string[] + /** How many of the newest versions above the installed one a version listing reads and returns. */ + versionListLimit?: number + /** How many single-version registry lookups a version listing runs at once. */ + versionLookupConcurrency?: number + /** Largest bundle detail document, and largest locale file read for one, in bytes. */ + detailMaxBytes?: number + /** Largest image served beside a bundle detail document, in bytes. */ + detailImageMaxBytes?: number + /** Deadline of one npm file CDN request for an offered bundle's detail document. */ + detailFetchTimeoutMs?: number + /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources?: NpmFileSource[] } +/** The Fetch route serving images beside bundle detail documents. */ +const DETAIL_FILE_PATH = '/api/plugin-manager/detail-file' + /** An http(s) URL, as pnpm's `--registry` takes it. */ const REGISTRY_URL = /^https?:\/\/\S+$/ @@ -80,6 +103,9 @@ const protectedModules = new Set([ /** The profile files an installation writes and a failed or cancelled one restores. */ const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const +/** The manifest fields an inspection reads for a registry spec. */ +const INSPECT_FIELDS = ['name', 'version', 'description', 'dsh'] as const + /** pnpm's colour escapes, which a JSON answer may be wrapped in. */ const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g @@ -136,10 +162,71 @@ function inspectionOf(kind: 'registry' | 'path', manifest: object, registry: Reg } } -function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection { +function refused(problem: Extract['problem'], reason: string): PluginSpecInspection { return { status: 'refused', problem, reason } } +/** An installed registry bundle an update can replace, its installed version, and its parsed registry spec. */ +interface UpdateTarget { + bundle: B + installed: string + parsed: Extract +} + +/** + * Why an update cannot replace a listed bundle, or what it replaces. + * @param bundle The listed bundle, or undefined for a name the inventory does not list. + * @param name The package name, for the refusal. + * @returns The bundle, its installed version, and its registry spec; or the refusal's diagnostic. + */ +function updateRefusal>( + bundle: B | undefined, name: string, +): UpdateTarget | string { + if (bundle === undefined || !bundle.installed || bundle.availability !== 'profile' || bundle.official || bundle.source === undefined) { + return `${name} is not a third-party package installed in this profile` + } + // A management bundle cannot be removed, so it cannot be replaced either. + if (bundle.readOnlyReason !== undefined) return `${bundle.name} carries plugin management and cannot be removed` + const installed = bundle.version + if (installed === undefined || semver.valid(installed) === null) return `${bundle.name} declares no semver version` + const parsed = parsedForRegistry(bundle.source) + // A protocol in the saved range, such as an `npm:` alias, names another package than the dependency. + if (parsed.kind !== 'registry' || parsed.name !== bundle.name || parsed.range?.includes(':') === true) { + return `${bundle.name} is not installed from a registry under its own name` + } + return { bundle, installed, parsed } +} + +/** What asking planned registries in turn answered: the parsed output and the registry that gave it, or the last refusal. */ +type RegistryAnswer = + | { status: 'answered'; answer: unknown; registry: Registry; attempts: RegistryAttempt[] } + | { status: 'refused'; problem: NonNullable; reason: string; attempts: RegistryAttempt[] } + +/** + * Map items with at most `limit` calls in flight, stopping new calls once one answers `stop`. + * @param items - the items, in order. + * @param limit - the most calls in flight. + * @param map - the call for one item. + * @param stop - whether an answer ends the remaining calls. + * @returns the answers of the calls that ran, in item order; items after a stop stay absent. + */ +async function mapConcurrent( + items: readonly T[], limit: number, map: (item: T) => Promise, stop: (answer: R) => boolean, +): Promise<(R | undefined)[]> { + const answers: (R | undefined)[] = items.map(() => undefined) + let next = 0 + let stopped = false + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { + while (!stopped && next < items.length) { + const index = next++ + const answer = await map(items[index] as T) + answers[index] = answer + if (stop(answer)) stopped = true + } + })) + return answers +} + /** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */ function printedError(printed: string): string { let parsed: unknown @@ -215,6 +302,12 @@ export class PluginManager extends TypertRemoteService { idleTimeoutMs: z.number().step(1).min(1000).default(600000), registry: z.string().pattern(REGISTRY_URL), fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]), + versionListLimit: z.number().step(1).min(1).default(20), + versionLookupConcurrency: z.number().step(1).min(1).default(4), + detailMaxBytes: z.natural().min(1).default(1024 * 1024), + detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024), + detailFetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000), + detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]), }) /** Management bundles remain protected if their files become unreadable. */ private readonly managementBundles = new Set() @@ -232,11 +325,14 @@ export class PluginManager extends TypertRemoteService { private readonly githubConnectionTimeoutMs: number private readonly idleTimeoutMs: number private readonly pnpmCommand: string + private readonly versionListLimit: number + private readonly versionLookupConcurrency: number private readonly configuredRegistries: Omit private readonly ownerContext: Context private readonly abort = new AbortController() /** Installations by request id, from their call until it settles. */ private readonly installs = new Map() + private readonly detail: BundleDetailOptions constructor(ctx: Context, config: Config) { super(ctx, 'pluginManager') @@ -250,14 +346,70 @@ export class PluginManager extends TypertRemoteService { this.githubConnectionTimeoutMs = (config as Required).githubConnectionTimeoutMs this.idleTimeoutMs = (config as Required).idleTimeoutMs this.pnpmCommand = (config as Required).pnpmCommand + this.versionListLimit = (config as Required).versionListLimit + this.versionLookupConcurrency = (config as Required).versionLookupConcurrency this.configuredRegistries = { registry: config.registry === undefined ? null : normalizeRegistry(config.registry), fallbackRegistries: (config as Required).fallbackRegistries.map(normalizeRegistry), } + const required = config as Required + this.detail = { + profile: this.profile, maxDocumentBytes: required.detailMaxBytes, maxImageBytes: required.detailImageMaxBytes, + npmFileSources: required.detailNpmFileSources, timeoutMs: required.detailFetchTimeoutMs, lifetime: this.abort.signal, + } ctx.effect(() => async () => { this.abort.abort() await Promise.allSettled([...this.packageOperations]) }, 'plugin-manager: package cancellation') + ctx.inject(['connection'], (scope) => { + scope.effect(() => scope.connection.fetch.register({ + path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request), + }), `plugin-manager: ${DETAIL_FILE_PATH}`) + }) + } + + /** + * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its + * README. An installed bundle is read from its directory without network access; an official bundle the installation + * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached. + * @param name - the bundle's package name, as `listBundles()` lists it. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one + * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked. + * @returns the document with its origin and path, or `none`. + * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or + * `plugin-manager/detail-unavailable`. + */ + @Remote + async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise { + return await readBundleDocument(await this.listedBundle(name), language, options?.npmFileSources ?? [], this.detail) + } + + private async listedBundle(name: string): Promise { + const bundle = (await this.listBundles()).find(candidate => candidate.name === name) + if (bundle === undefined) throw new RemoteError('plugin-manager/unknown-bundle', `No such bundle: ${name}`, { name }) + return bundle + } + + /** + * Answer `GET ${DETAIL_FILE_PATH}?bundle=&document=&path=[&sources=,...]` with one image + * beside a bundle document; `sources` names npm file CDNs to ask first for an offered bundle, as `readBundleDetail` does. + */ + private async serveDetailFile(request: Request): Promise { + const params = new URL(request.url).searchParams + const [bundle, document, path] = [params.get('bundle'), params.get('document'), params.get('path')] + // Images are read again on every request, so a reinstalled bundle shows its own. + const cache = 'no-store' + if (bundle === null || document === null || path === null) return detailImageResponse('invalid', cache) + try { + const image = await readBundleImage(await this.listedBundle(bundle), document, path, params.get('sources')?.split(',') ?? [], this.detail) + return detailImageResponse(image, cache) + } catch (error) { + const failure = remoteErrorOf(error) + if (failure?.code === 'plugin-manager/unknown-bundle') return detailImageResponse(undefined, cache) + if (failure?.code === 'plugin-manager/detail-unavailable') return detailImageResponse(failure.details.reason, cache) + throw error + } } /** Read exact plugin-version exemptions saved in this profile. @@ -314,6 +466,11 @@ export class PluginManager extends TypertRemoteService { */ @Remote listBundles(): Promise { + return Promise.resolve(this.readBundles().map(bundle => ({ ...bundle, updatable: typeof updateRefusal(bundle, bundle.name) !== 'string' }))) + } + + /** The bundle inventory before update eligibility, which is derived from each listed bundle. */ + private readBundles(): Omit[] { const manifest = readProfileManifest('dsh', this.profile.dir) const exemptions = readProfileVersionExemptions(this.profile.dir) const selected = bundleSelection(manifest) @@ -322,7 +479,7 @@ export class PluginManager extends TypertRemoteService { const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest const catalog = new Map(OFFICIAL_ON_DEMAND_CATALOG.map(entry => [entry.packageName, entry])) const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {}), ...catalog.keys()])] - const bundles: BundleInfo[] = [] + const bundles: Omit[] = [] const runtimeVersion = getDshRuntimeVersion() for (const name of names) { const installed = dependencies.includes(name) @@ -382,7 +539,7 @@ export class PluginManager extends TypertRemoteService { } } } - return Promise.resolve(bundles) + return bundles } /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. @@ -442,47 +599,193 @@ export class PluginManager extends TypertRemoteService { } case 'registry': { if (known.has(parsed.name)) return refused('already-installed', `${parsed.name} is already installed`) - const registries: Registry[] = [] - const refusedBy = (problem: PluginInspectProblem, reason: string): PluginSpecInspection => - ({ status: 'refused', problem, reason, registries }) - for (const current of plan) { - registries.push(current) - const view = await viewProfilePackage(this.profile.dir, spec.trim(), { - ...this.profile.packageManager ?? { command: this.pnpmCommand }, - timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal }, registry: current, - }) - const printed = view.stdout.replace(ANSI_SEQUENCE, '').trim() - if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) { - // pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr. - const log = [view.stderr.trim(), printedError(printed), view.cause === undefined ? '' : messageOf(view.cause)].filter(Boolean).join('\n') - const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } }) - // A lookup the caller dropped is not carried to the next registry. - if (registries.length < plan.length && signal?.aborted !== true && attributeFailure(kind, log, parsed) === 'registry') continue - const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}` - if (kind === 'not-found' || kind === 'no-matching-version') return refusedBy('not-found', reason) - if (kind === 'network' || kind === 'timeout') return refusedBy('network', reason) - return refusedBy('unknown', reason) - } - let answer: unknown - try { - answer = JSON.parse(printed || 'null') - } catch (error) { - return refusedBy('unknown', `unreadable pnpm view output: ${messageOf(error)}`) - } - // A range answers one object per matching version, oldest first. - const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer - if (typeof latest !== 'object' || latest === null) return refusedBy('unknown', 'pnpm view answered no package') - const inspection = inspectionOf('registry', latest, current) - const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection - if (!named.bundle) return refusedBy('not-a-bundle', `${named.name} declares no dsh.bundle`) - return named - } - /* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */ - throw new Error('no registry was asked') + const asked = await this.askRegistries(spec.trim(), INSPECT_FIELDS, plan, parsed, signal) + const registries = asked.attempts.map(attempt => attempt.registry) + if (asked.status === 'refused') return { status: 'refused', problem: asked.problem, reason: asked.reason, registries } + // pnpm prints a range's highest match as one object; npm's array of every match is read by its last entry. + const latest: unknown = Array.isArray(asked.answer) ? asked.answer.at(-1) : asked.answer + if (typeof latest !== 'object' || latest === null) return { status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package', registries } + const inspection = inspectionOf('registry', latest, asked.registry) + const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection + if (!named.bundle) return { status: 'refused', problem: 'not-a-bundle', reason: `${named.name} declares no dsh.bundle`, registries } + return named } } } + /** Read the published versions newer than an installed registry package's version, from the first registry that answers. + * Official bundles follow the DSH version and are not listed. + * @param name Installed profile dependency name. + * @param options The registry asked first; absent, the one the package was installed from in this process, else the configured one. + * @param signal Ends the lookups early. + * @returns At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration, + * and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked. + */ + @Remote + async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise { + const target = updateRefusal((await this.listBundles()).find(bundle => bundle.name === name), name) + if (typeof target === 'string') return { status: 'refused', problem: 'not-updatable', reason: target, attempts: [] } + const plan = registryPlan(this.registryFor(name, options?.registry), await this.registries()) + const listing = await this.askRegistries(name, VERSION_LIST_FIELDS, plan, target.parsed, signal) + if (listing.status === 'refused') return listing + let newer + try { newer = newerVersions(listing.answer, target.installed) } + catch (error) { return { status: 'refused', problem: 'unknown', reason: messageOf(error), attempts: listing.attempts } } + const listed = newer.slice(0, this.versionListLimit) + const exemptions = readProfileVersionExemptions(this.profile.dir) + const looked = await mapConcurrent(listed, this.versionLookupConcurrency, async (published) => { + const spec = `${name}@${published.version}` + const answer = await this.askRegistries(spec, VERSION_FIELDS, [listing.registry], { kind: 'registry', spec, name }, signal) + if (answer.status === 'refused') return answer + const facts = versionFacts(answer.answer, name, published.version) + let compatibility + try { compatibility = evaluatePluginCompatibility(facts.manifest, exemptions) } + catch (error) { return { status: 'refused' as const, problem: 'unknown' as const, reason: messageOf(error), attempts: answer.attempts } } + const version: PluginVersion = { + ...published, bundle: facts.bundle, ...facts.deprecated === undefined ? {} : { deprecated: facts.deprecated }, + ...compatibility === undefined || compatibility.exempted ? {} : { incompatible: incompatiblePlugin(compatibility) }, + } + return { status: 'read' as const, version } + }, answer => answer.status === 'refused') + const versions: PluginVersion[] = [] + for (const answer of looked) { + /* v8 ignore next -- a lookup stops the rest only by refusing, which returns below */ + if (answer === undefined) continue + if (answer.status === 'refused') return { ...answer, attempts: [...listing.attempts.slice(0, -1), ...answer.attempts] } + versions.push(answer.version) + } + return { + status: 'listed', installed: target.installed, registry: listing.registry, attempts: listing.attempts, + versions, omitted: newer.length - listed.length, + } + } + + /** + * Replace an installed registry bundle with a newer published version. The target is checked on the registry first: + * it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed + * version is removed, so its plugins stop, and the target is installed with the same selection; when that + * installation fails or is cancelled, the removed version is installed again from the registry it came from. + * Without HMR the target is installed over the running version and the result reports the restart. + * @param name Installed profile dependency name. + * @param version Exact published version newer than the installed one. + * @param options The registry asked first, as for `listVersions`, and the request id naming the progress and the + * target installation's logs and cancellation. + * @returns The versions, the last step that ran with its result, and the reinstallation when one ran. + */ + @Remote + async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise { + const requestId = options?.requestId + const announce = (step: PluginUpdateStep): void => { + if (requestId !== undefined) this.ownerContext.emit('plugin-manager/update-state', { requestId, step }) + } + announce('checking') + const target = updateRefusal((await this.listBundles()).find(item => item.name === name), name) + const from = typeof target === 'string' ? '' : target.installed + const refusedBefore = (error: ManagementError): PluginUpdateResult => ({ + from, to: version, step: 'check', + result: { changed: false, application: 'failed', stage: 'install', target: name, error }, + }) + if (typeof target === 'string') return refusedBefore({ code: 'invalid-spec', diagnostic: target }) + const { bundle } = target + if (semver.valid(version) === null || !semver.gt(version, from)) { + return refusedBefore({ code: 'invalid-spec', diagnostic: `${version} is not a version newer than the installed ${from}` }) + } + const spec = `${name}@${version}` + const plan = registryPlan(this.registryFor(name, options?.registry), await this.registries()) + const checked = await this.askRegistries(spec, VERSION_FIELDS, plan, { kind: 'registry', spec, name }) + if (checked.status === 'refused') return refusedBefore({ code: 'operation-error', diagnostic: checked.reason }) + const facts = versionFacts(checked.answer, name, version) + if (!facts.bundle) return refusedBefore({ code: 'not-bundle' }) + let compatibility + try { compatibility = evaluatePluginCompatibility(facts.manifest, readProfileVersionExemptions(this.profile.dir)) } + catch (error) { return refusedBefore({ code: 'operation-error', diagnostic: messageOf(error) }) } + if (compatibility !== undefined && !compatibility.exempted) { + return refusedBefore({ code: 'incompatible-version', incompatible: [incompatiblePlugin(compatibility)] }) + } + const { enabled } = bundle + // A lock timeout or disposal rejects instead of reporting; it reads as the failed step it is. + const settled = (stage: ChangeResult['stage'], target: string, operation: Promise): Promise => + operation.catch((error: unknown) => ({ changed: false, application: 'failed', stage, target, error: managementError(error) })) + const installTarget = (): Promise => settled('install', spec, this.installBundle(spec, { + saveExact: true, registry: checked.registry, enabled, ...requestId === undefined ? {} : { requestId }, + })) + if (this.ownerContext.get('hmr') === undefined) { + announce('installing') + return { from, to: version, step: 'install', result: await installTarget() } + } + // The removed version is installed again where it came from, else over the configured plan, as its removal asks. + const previousRegistry = this.installedFrom.get(name) + announce('removing') + const removal = await settled('remove', name, this.removeBundle(name)) + if (removal.application === 'failed' || removal.application === 'cancelled') return { from, to: version, step: 'remove', result: removal } + announce('installing') + const result = await installTarget() + if (result.application !== 'failed' && result.application !== 'cancelled') return { from, to: version, step: 'install', result } + announce('restoring') + // A target that installed but failed to start is still a dependency; it goes before the removed version returns. + if (result.stage === 'enable') await settled('remove', name, this.removeBundle(name)) + // The reinstallation carries no request id, so cancelling the update cannot leave the package removed. + const previous = `${name}@${from}` + const restore = await settled('install', previous, this.installBundle(previous, { + saveExact: true, enabled, ...previousRegistry === undefined ? {} : { registry: previousRegistry }, + })) + return { from, to: version, step: 'install', result, restore } + } + + /** + * The registry a version listing or an update asks first. + * @param name The package name. + * @param requested The caller's registry, when it named one. + * @returns The requested registry, else the one this process last installed the package from, else undefined for the configured one. + */ + private registryFor(name: string, requested: Registry | undefined): Registry | undefined { + return requested !== undefined ? requested : this.installedFrom.get(name) + } + + /** + * Ask registries in turn through `pnpm view`, moving to the next one while a failure is the registry's: unreachable, + * timed out, or without the package or version, which a mirror not yet synced reports. + * @param spec The registry spec viewed. + * @param fields The manifest fields printed. + * @param plan The registries, in order; never empty. + * @param parsed The spec's form, which decides whether a failure was the registry's. + * @param signal Ends the lookup; a dropped lookup is not carried to the next registry. + * @returns The parsed answer and the registry that gave it, or the last refusal, with every registry asked. + */ + private async askRegistries( + spec: string, fields: readonly string[], plan: readonly Registry[], parsed: ParsedInstallSpec, signal?: AbortSignal, + ): Promise { + const attempts: RegistryAttempt[] = [] + for (const [index, registry] of plan.entries()) { + const view = await viewProfilePackage(this.profile.dir, spec, { + ...this.profile.packageManager ?? { command: this.pnpmCommand }, + fields, timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal }, registry, + }) + const printed = view.stdout.replace(ANSI_SEQUENCE, '').trim() + if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) { + // pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr. + const log = [view.stderr.trim(), printedError(printed), view.cause === undefined ? '' : messageOf(view.cause)].filter(Boolean).join('\n') + const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } }) + const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}` + const problem = kind === 'not-found' || kind === 'no-matching-version' ? 'not-found' : kind === 'network' || kind === 'timeout' ? 'network' : 'unknown' + attempts.push({ registry, problem, reason }) + if (index < plan.length - 1 && signal?.aborted !== true && attributeFailure(kind, log, parsed) === 'registry') continue + return { status: 'refused', problem, reason, attempts } + } + try { + const answer: unknown = JSON.parse(printed || 'null') + attempts.push({ registry }) + return { status: 'answered', answer, registry, attempts } + } catch (error) { + const reason = `unreadable pnpm view output: ${messageOf(error)}` + attempts.push({ registry, problem: 'unknown', reason }) + return { status: 'refused', problem: 'unknown', reason, attempts } + } + } + /* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */ + throw new Error('no registry was asked') + } + /** Persist a plugin entry's desired enablement and apply it on live profiles. * @param id Loader entry identity returned by listPlugins. * @param enabled Whether the plugin should run. diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index b9a36d6083..d3452139fe 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -583,6 +583,8 @@ export interface PackageViewOptions { timeoutMs: number /** The registry asked; null asks the one pnpm's own configuration names. */ registry?: Registry + /** The manifest fields printed, as `pnpm view` names them; registry-only fields such as `versions`, `time`, and `dist-tags` included. */ + fields: readonly string[] } /** @@ -620,12 +622,12 @@ export function registryArguments(registry: Registry): string[] { * configured after it are the retry. * @param dir Profile directory. * @param spec One registry spec: a package name with an optional range. - * @param options The registry, cancellation, and the time bound. + * @param options The fields printed, the registry, cancellation, and the time bound. * @returns pnpm's exit, output, and how the lookup ended. */ export async function viewProfilePackage(dir: string, spec: string, options: PackageViewOptions): Promise { const result = await execa(options.command ?? 'pnpm', [ - ...options.args ?? [], 'view', spec, 'name', 'version', 'description', 'dsh', '--json', + ...options.args ?? [], 'view', spec, ...options.fields, '--json', ...registryArguments(options.registry ?? null), '--config.fetch-retries=0', ], { cwd: dir, env: { ...scrubbedParentEnv(), ...options.env }, extendEnv: false, reject: false, stdin: 'ignore', diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts index 824deb261b..2addbc05ba 100644 --- a/packages/boot/plugin-manager/src/tools.ts +++ b/packages/boot/plugin-manager/src/tools.ts @@ -128,7 +128,10 @@ export function apply(ctx: Context): void { const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles() const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) => 'rows' in row - ? { ...row, rows: row.rows.map(({ meta: _rowMeta, ...declared }) => declared) } + // The tool offers no update action, so update eligibility stays out of the model's view. + ? (({ updatable: _updatable, ...bundle }) => ({ + ...bundle, rows: bundle.rows.map(({ meta: _rowMeta, ...declared }) => declared), + }))(row) : row) return JSON.stringify({ entries, total: rows.length, nextOffset: offset + entries.length < rows.length ? offset + entries.length : null }) diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 4cd9d9bfe3..e4909d5555 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -4,6 +4,23 @@ import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' +import type {} from '@deepseek-ai/dsh-typert-protocol' +import type { DetailFailureReason } from '@deepseek-ai/dsh-plugin-detail/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The profile lists no such bundle, or its directory cannot be resolved. */ + 'plugin-manager/unknown-bundle': { readonly name: string } + /** The display language is not a BCP 47 language id. */ + 'plugin-manager/invalid-language': { readonly language: string } + /** + * A bundle's detail document or image exists but cannot be read: `too-large` beyond a size limit, `network` when the + * npm file CDNs cannot be reached, `invalid` for a malformed locale file or `meta.detail`, or a named file that does + * not exist. Bundle files come from local directories and public CDNs, which never answer `denied`. + */ + 'plugin-manager/detail-unavailable': { readonly reason: DetailFailureReason } + } +} /** Reasons a profile control cannot modify its target. */ export type ReadOnlyReason = 'management-required' | 'unaddressable' @@ -81,6 +98,11 @@ export interface BundleInfo { */ optional: boolean removable: boolean + /** + * Whether `listVersions` and `updateBundle` accept the bundle: a third-party profile dependency saved as a registry + * spec under its own name, whose installed manifest declares a semver version, and that carries no plugin management. + */ + updatable: boolean readOnlyReason?: ReadOnlyReason error?: ManagementError /** The rows the bundle's patch inserts, in declaration order; empty when the patch cannot be read. */ @@ -222,6 +244,91 @@ export type PluginSpecInspection = readonly registries?: Registry[] } +/** One registry a lookup asked, in order; the registry that answered carries no problem. */ +export interface RegistryAttempt { + readonly registry: Registry + /** Why this registry gave no usable answer; absent for the registry that answered. */ + readonly problem?: 'not-found' | 'network' | 'unknown' + /** What pnpm or the registry said, verbatim. */ + readonly reason?: string +} + +/** One published version newer than the installed one. */ +export interface PluginVersion { + readonly version: string + /** The registry's publication time, as an ISO 8601 string. */ + readonly publishedAt?: string + /** The dist-tags pointing at this version, such as `latest`. */ + readonly tags: readonly string[] + /** Whether the version carries a semver prerelease suffix. */ + readonly prerelease: boolean + /** The registry's deprecation message, when the version is deprecated. */ + readonly deprecated?: string + /** Whether the version declares a `dsh.bundle`; a version that does not cannot replace a bundle. */ + readonly bundle: boolean + /** Present when the version's `engines.dsh` or DSH peers reject the running DSH version without an exemption for the exact pair. */ + readonly incompatible?: IncompatiblePlugin +} + +/** The versions a registry offers for an installed registry package, read without changing the profile. */ +export type PluginVersionList = + | { + readonly status: 'listed' + /** The installed version the list is newer than. */ + readonly installed: string + /** The registry that answered; an update installs from it. */ + readonly registry: Registry + readonly attempts: readonly RegistryAttempt[] + /** Published versions newer than `installed`, newest first, at most `versionListLimit` of them. */ + readonly versions: readonly PluginVersion[] + /** Newer versions beyond `versionListLimit` that the list leaves out. */ + readonly omitted: number + } + | { + readonly status: 'refused' + /** `not-updatable`: the package is not an installed profile dependency resolved from a registry. */ + readonly problem: 'not-updatable' | 'not-found' | 'network' | 'unknown' + readonly reason: string + /** The registries asked, in order; empty for `not-updatable`. */ + readonly attempts: readonly RegistryAttempt[] + } + +/** Where a version listing asks. */ +export interface ListVersionsOptions { + /** The registry asked first; absent, the one this process last installed the package from, else the configured one. */ + readonly registry?: Registry +} + +/** Where an update asks for its target, and the request id its progress names. */ +export interface UpdateBundleOptions { + /** The registry asked first, normally the one that answered `listVersions`; absent, as for `listVersions`. */ + readonly registry?: Registry + /** Names the update's progress and the target installation's logs; `cancelInstall` stops only that installation. */ + readonly requestId?: PluginInstallRequestId +} + +/** The step of an update its Host progress names. */ +export type PluginUpdateStep = 'checking' | 'removing' | 'installing' | 'restoring' + +/** Progress of one update request. */ +export interface PluginUpdateProgress { + readonly requestId: PluginInstallRequestId + readonly step: PluginUpdateStep +} + +/** + * How an update ended. `result` belongs to the last step that ran: the target check, the removal of the installed + * version, or the target's installation. `restore` is present when the target's installation failed or was cancelled + * after the installed version was removed, and reports the reinstallation of that version. + */ +export interface PluginUpdateResult { + readonly from: string + readonly to: string + readonly step: 'check' | 'remove' | 'install' + readonly result: ChangeResult + readonly restore?: ChangeResult +} + /** The Host phase of one installation, before its install call settles; `installing` is announced once per registry asked. */ export interface PluginInstallProgress { readonly requestId: PluginInstallRequestId @@ -251,6 +358,22 @@ export interface PluginInstallLogChunk { readonly exitCode?: number | null } +/** + * A bundle's long description, or `none` when it has neither a `meta.detail` file nor a README. Images resolve beside + * `path` through `GET /api/plugin-manager/detail-file?bundle=&document=&path=`. + */ +export type BundleDetail = + | { + readonly status: 'found' + /** `locale` for the file locale `meta.detail` names, `readme` for the package's README. */ + readonly origin: 'locale' | 'readme' + /** `/`-separated path of the document inside the package. */ + readonly path: string + /** Markdown text without a byte order mark or a leading YAML front matter block. */ + readonly markdown: string + } + | { readonly status: 'none' } + /** What changed in the profile, for consumers that show it. */ export interface PluginChange { /** The operation that changed it. */ @@ -280,5 +403,12 @@ declare module '@deepseek-ai/cordis' { * @param progress - the installation's request id and phase, with the attempt while installing. */ 'plugin-manager/install-state'(progress: PluginInstallProgress): void + /** + * An update moved to its next step: checking the target, removing the installed version, installing the target, + * or reinstalling the removed version after the target failed. Announced once per step. + * @mode emit + * @param progress - the update's request id and step. + */ + 'plugin-manager/update-state'(progress: PluginUpdateProgress): void } } diff --git a/packages/boot/plugin-manager/src/versions.ts b/packages/boot/plugin-manager/src/versions.ts new file mode 100644 index 0000000000..7b5d75c08a --- /dev/null +++ b/packages/boot/plugin-manager/src/versions.ts @@ -0,0 +1,80 @@ +/** Read `pnpm view` answers about a package's published versions. */ +import semver from 'semver' + +/** The fields a version listing prints for the package: every version, its publication times, and its dist-tags. */ +export const VERSION_LIST_FIELDS = ['versions', 'time', 'dist-tags'] as const + +/** The fields read for one version: what decides whether it can replace an installed bundle. */ +export const VERSION_FIELDS = ['name', 'version', 'engines', 'peerDependencies', 'deprecated', 'dsh'] as const + +/** One published version newer than the installed one, before its own manifest is read. */ +export interface PublishedVersion { + readonly version: string + readonly publishedAt?: string + readonly tags: readonly string[] + readonly prerelease: boolean +} + +/** What one version's manifest says about replacing an installed bundle. */ +export interface VersionFacts { + /** The fields `evaluatePluginCompatibility` reads. */ + readonly manifest: { name: string; version: string; engines?: unknown; peerDependencies?: unknown } + readonly bundle: boolean + readonly deprecated?: string +} + +/** A JSON object, or undefined for anything else. */ +function recordOf(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : undefined +} + +/** + * The published versions newer than the installed one, newest first. + * @param answer - parsed `pnpm view versions time dist-tags --json` output. + * @param installed - the installed version. + * @returns the newer versions with their publication times and dist-tags. + * @throws when the answer names no versions. + */ +export function newerVersions(answer: unknown, installed: string): PublishedVersion[] { + const fields = recordOf(answer) + // pnpm prints a package with one version as a string rather than a one-element array. + const listed: unknown = typeof fields?.versions === 'string' ? [fields.versions] : fields?.versions + if (!Array.isArray(listed)) throw new Error('pnpm view answered no versions') + const time = recordOf(fields?.time) ?? {} + const tags = Object.entries(recordOf(fields?.['dist-tags']) ?? {}) + return listed + .filter((version): version is string => typeof version === 'string' && semver.valid(version) !== null && semver.gt(version, installed)) + .sort(semver.rcompare) + .map((version) => { + const published = time[version] + return { + version, + ...typeof published === 'string' ? { publishedAt: published } : {}, + tags: tags.filter(([, tagged]) => tagged === version).map(([tag]) => tag), + prerelease: semver.prerelease(version) !== null, + } + }) +} + +/** + * What one exact version's manifest says, from `pnpm view @ name version engines peerDependencies deprecated dsh --json`. + * @param answer - the parsed output. + * @param name - the package name, used when the answer omits it. + * @param version - the version asked for, used when the answer omits it. + * @returns the manifest fields compatibility reads, whether the version declares a bundle, and its deprecation message. + */ +export function versionFacts(answer: unknown, name: string, version: string): VersionFacts { + const fields = recordOf(answer) ?? {} + const dsh = recordOf(fields.dsh) + const deprecated = fields.deprecated + return { + manifest: { + name: typeof fields.name === 'string' ? fields.name : name, + version: typeof fields.version === 'string' ? fields.version : version, + ...Object.hasOwn(fields, 'engines') ? { engines: fields.engines } : {}, + ...Object.hasOwn(fields, 'peerDependencies') ? { peerDependencies: fields.peerDependencies } : {}, + }, + bundle: recordOf(dsh?.bundle) !== undefined, + ...typeof deprecated === 'string' && deprecated !== '' ? { deprecated } : {}, + } +} diff --git a/packages/boot/plugin-manager/tests/bundle-detail.spec.ts b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts new file mode 100644 index 0000000000..8f1de9641c --- /dev/null +++ b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts @@ -0,0 +1,177 @@ +/** Bundle detail documents and their images through a real profile Include and Loader. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Service, type Context } from '@deepseek-ai/cordis' +import { boot, getDshRuntimeVersion, initProfile, loadProfileDirectory, OFFICIAL_ON_DEMAND_CATALOG, readProfileManifest, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import PluginManager, { type Config } from '../src/index.ts' +import * as operations from '../src/operations.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +/** The `connection` Fetch route registry, recording the routes the manager registers. */ +class RecordingConnection extends Service { + /** The instance the latest fixture mounted. */ + static current: RecordingConnection | undefined + readonly routes = new Map Promise>() + readonly fetch = { + register: (route: { path: string; fetch: (request: Request) => Promise }) => { + this.routes.set(route.path, route.fetch) + return () => Promise.resolve(void this.routes.delete(route.path)) + }, + } + + constructor(ctx: Context) { + super(ctx, 'connection') + RecordingConnection.current = this + } +} + +async function fixture(config: Config = {}) { + const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-detail-'))) + let owner: Context | undefined + onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) + const dir = join(home, 'profiles', 'test') + writeFileSync(join(home, 'package.json'), JSON.stringify({ name: 'installation', dependencies: {} })) + initProfile(dir, ['core', 'documented']) + const bundle = (name: string, files: Record) => { + const path = join(dir, 'node_modules', name) + for (const [file, contents] of Object.entries({ 'cordis.patch.yml': '[]', ...files })) { + mkdirSync(join(path, file, '..'), { recursive: true }) + writeFileSync(join(path, file), contents) + } + writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + } + bundle('core', { 'cordis.patch.yml': JSON.stringify([{ insert: [{ id: 'manager', name: 'cordis:manager', config }, { id: 'connection', name: 'cordis:connection' }] }]) }) + bundle('documented', { + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'locale/zh.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.zh.md' } }), + 'guide/DETAIL.md': '---\ntitle: x\n---\n# Documented\n\n![shot](img/shot.svg)\n', + 'guide/DETAIL.zh.md': '# 有说明\n', + 'guide/img/shot.svg': '', + }) + bundle('plain', { 'readme.md': '# Plain' }) + bundle('bare', {}) + bundle('broken', { 'locale/en.json': JSON.stringify({ meta: { detail: '../outside.md' } }) }) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { documented: '1.0.0', plain: '1.0.0', bare: '1.0.0', broken: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const profile: ProfileContext = { + name: 'test', startedBundles: loadProfileDirectory('test', dir, join(home, 'package.json')).layers.map(layer => layer.packageName), + dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + } + const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => { + owner = root + root.provide('profileContext', profile) + Object.assign(root.loader.builtins, { manager: PluginManager, connection: RecordingConnection }) + }) + const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/') + onTestFinished(() => { registry.mockRestore() }) + const connection = RecordingConnection.current as RecordingConnection + const image = (query: string) => (connection.routes.get('/api/plugin-manager/detail-file') as (request: Request) => Promise)( + new Request(`http://dsh.local/api/plugin-manager/detail-file?${query}`)) + return { manager: ctx.pluginManager, image, connection } +} + +const failureOf = async (promise: Promise) => remoteErrorOf(await promise.catch((error: unknown) => error)) + +it('reads an installed bundle\'s localized meta.detail file or README from its directory', async () => { + const fetch = vi.fn(() => Promise.reject(new Error('no network'))) + vi.stubGlobal('fetch', fetch) + const { manager } = await fixture() + expect(await manager.readBundleDetail('documented', 'en-US')).toEqual({ + status: 'found', origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Documented\n\n![shot](img/shot.svg)\n', + }) + expect(await manager.readBundleDetail('documented', 'zh-CN')).toMatchObject({ origin: 'locale', markdown: '# 有说明\n' }) + expect(await manager.readBundleDetail('plain', 'en')).toEqual({ status: 'found', origin: 'readme', path: 'readme.md', markdown: '# Plain' }) + expect(await manager.readBundleDetail('bare', 'en')).toEqual({ status: 'none' }) + expect(fetch).not.toHaveBeenCalled() + expect(await failureOf(manager.readBundleDetail('broken', 'en'))).toMatchObject({ code: 'plugin-manager/detail-unavailable', details: { reason: 'invalid' } }) + expect(await failureOf(manager.readBundleDetail('absent', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'absent' } }) + expect(await failureOf(manager.readBundleDetail('plain', 'x y'))).toMatchObject({ code: 'plugin-manager/invalid-language', details: { language: 'x y' } }) +}) + +it('serves images beside a bundle document without caching and refuses other paths', async () => { + const { image, manager, connection } = await fixture({ detailImageMaxBytes: 20 }) + const ok = await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg') + expect([ok.status, ok.headers.get('content-type'), ok.headers.get('cache-control'), ok.headers.get('content-security-policy')?.startsWith('sandbox')]) + .toEqual([413, 'text/plain; charset=utf-8', 'no-store', true]) + expect(await ok.text()).toBe('too-large') + expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=..%2F..%2Fpackage.json')).status).toBe(404) + expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fmissing.png')).status).toBe(404) + expect((await image('bundle=absent&document=README.md&path=a.png')).status).toBe(404) + expect((await image('bundle=documented&document=README.md')).status).toBe(400) + const read = vi.spyOn(manager, 'listBundles').mockRejectedValueOnce(new TypeError('bug')) + await expect(image('bundle=documented&document=README.md&path=a.png')).rejects.toThrow('bug') + read.mockRestore() + const { image: roomy } = await fixture() + const served = await roomy('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg') + expect([served.status, served.headers.get('content-type')]).toEqual([200, 'image/svg+xml']) + expect(await served.text()).toContain(' { + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const version = getDshRuntimeVersion() + const fetch = vi.fn((url: string) => Promise.resolve(url === `https://registry.npmmirror.com/${name}/${version}/files/README.md` + ? new Response('# Official') + : url.includes('/files/') ? new Response('missing', { status: 404 }) : Promise.reject(new Error(`unexpected ${url}`)))) + vi.stubGlobal('fetch', fetch) + const { manager, image } = await fixture() + expect(await manager.readBundleDetail(name, 'en', { npmFileSources: ['npmmirror'] })).toEqual({ status: 'found', origin: 'readme', path: 'README.md', markdown: '# Official' }) + expect(fetch.mock.calls[0]?.[0]).toBe(`https://registry.npmmirror.com/${name}/${version}/files/locale/en.json`) + fetch.mockClear() + expect((await image(`bundle=${encodeURIComponent(name)}&document=README.md&path=a.png&sources=npmmirror`)).status).toBe(404) + expect(fetch.mock.calls[0]?.[0]).toBe(`https://registry.npmmirror.com/${name}/${version}/files/a.png`) + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + expect(await failureOf(manager.readBundleDetail(name, 'en'))).toMatchObject({ code: 'plugin-manager/detail-unavailable', details: { reason: 'network' } }) + expect((await image(`bundle=${encodeURIComponent(name)}&document=README.md&path=a.png`)).status).toBe(502) +}) + +it.each([undefined, { npmFileSources: [] }])('asks the CDNs in configured order unless npmmirror is asked first (%o)', async (options) => { + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const version = getDshRuntimeVersion() + const fetch = vi.fn(() => Promise.resolve(new Response('missing', { status: 404 }))) + vi.stubGlobal('fetch', fetch) + const { manager } = await fixture({ detailNpmFileSources: ['unpkg', 'npmmirror'] }) + expect(await manager.readBundleDetail(name, 'en', options)).toEqual({ status: 'none' }) + expect(fetch.mock.calls[0]).toEqual([`https://unpkg.com/${name}@${version}/locale/en.json`, expect.anything()]) +}) + +it('shows no document for an offered bundle without a published version', async () => { + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const { manager } = await fixture() + const list = manager.listBundles.bind(manager) + vi.spyOn(manager, 'listBundles').mockImplementation(async () => (await list()).map(bundle => bundle.name === name + ? { ...bundle, installTarget: { spec: 'link:../x', version: 'link:../x' } } + : bundle)) + expect(await manager.readBundleDetail(name, 'en')).toEqual({ status: 'none' }) + expect((await failureOf(manager.readBundleDetail('documented', 'en')))).toBeUndefined() +}) + +it('reports a listed bundle whose directory is gone, and keeps failures other than detail failures', async () => { + const { manager, image } = await fixture() + const list = manager.listBundles.bind(manager) + const listing = vi.spyOn(manager, 'listBundles').mockImplementation(async () => [...await list(), { + name: 'vanished', official: false, availability: 'profile', enabled: false, installed: true, optional: false, removable: true, updatable: false, rows: [], overrides: [], + }]) + expect(await failureOf(manager.readBundleDetail('vanished', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'vanished' } }) + listing.mockRestore() + // A failure outside the detail rules, here decoding the document, reaches the caller unchanged. + const decoder = new TextDecoder() + const decoding = vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation((input) => { + if (decoding.mock.calls.length > 1) throw new TypeError('bug') + decoding.mockRestore() + const text = decoder.decode(input) + vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation(() => { throw new TypeError('bug') }) + return text + }) + await expect(manager.readBundleDetail('documented', 'en')).rejects.toThrow('bug') + vi.restoreAllMocks() + expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg')).status).toBe(200) +}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index da58379bef..b6cd4c21ad 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -302,12 +302,12 @@ it('lists bundle versions and current-profile plugin targets', async () => { expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBe('management-required') expect((await manager.listBundles()).filter(bundle => bundle.installTarget === undefined)).toEqual([ { - name: 'core', version: '1.0.0', enabled: true, installed: false, official: false, availability: 'profile', optional: false, removable: false, readOnlyReason: 'management-required', + name: 'core', version: '1.0.0', enabled: true, installed: false, official: false, availability: 'profile', optional: false, removable: false, updatable: false, readOnlyReason: 'management-required', meta: { title: 'core' }, rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [], }, { - name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, official: false, availability: 'profile', optional: false, removable: true, + name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: true, meta: { title: 'extra' }, rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [], }, @@ -329,7 +329,7 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({ - name: 'described', version: '2.0.0', description: 'Describes itself.', source: 'described@2.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, + name: 'described', version: '2.0.0', description: 'Describes itself.', source: 'described@2.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: true, meta: { title: 'described', description: 'Describes itself.' }, rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'], }) @@ -453,7 +453,7 @@ it.each([ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) expect((await manager.listBundles()).find(row => row.name === 'unnamed')).toEqual({ - name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, + name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: true, rows: [], overrides: [], ...expected, }) }) @@ -1025,11 +1025,11 @@ it('reads what a spec names before installing it', async () => { expect(await manager.inspect('dsh-x')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', bundle: true, registry: null, }) - expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', timeoutMs: 1000, registry: null }) + expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', fields: ['name', 'version', 'description', 'dsh'], timeoutMs: 1000, registry: null }) const signal = AbortSignal.abort() answers(JSON.stringify([{ name: 'dsh-lib', version: '1.0.0', dsh: { bundle: {} } }, { name: 'dsh-lib', version: '1.1.0', dsh: null }])) expect(await manager.inspect('dsh-lib@^1', undefined, signal)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-lib declares no dsh.bundle', registries: [null] }) - expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', timeoutMs: 1000, signal, registry: null }) + expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', fields: ['name', 'version', 'description', 'dsh'], timeoutMs: 1000, signal, registry: null }) // An answer that names no package keeps the name the spec gave; colour escapes around the JSON are dropped. answers('\x1b[36m' + JSON.stringify({ version: '0.0.1', description: '', dsh: { bundle: { patch: './p.yml' } } }) + '\x1b[39m\n') expect(await manager.inspect('dsh-bare')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-bare', version: '0.0.1', bundle: true, registry: null }) @@ -1147,7 +1147,7 @@ it.each(OPTIONAL_BUNDLES)('offers %s switched off and never removable', async (o expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({ name: offered, version: '3.0.0', description: 'Package one-liner.', meta: { title: offered, description: 'Package one-liner.' }, - enabled: false, installed: false, official: true, availability: 'installation', optional: true, removable: false, + enabled: false, installed: false, official: true, availability: 'installation', optional: true, removable: false, updatable: false, rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [], }) expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' }) @@ -1265,7 +1265,7 @@ it('asks the registries in turn while one is unreachable or stale, and names the expect(await manager.inspect('dsh-x')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.0.0', bundle: true, registry: MIRROR }) expect(asked(0)).toEqual([null, MIRROR]) expect(read).toHaveBeenCalledWith(dir, { command: 'pnpm', timeoutMs: 1000 }) - expect(view).toHaveBeenLastCalledWith(expect.any(String), 'dsh-x', { command: 'pnpm', timeoutMs: 1000, registry: MIRROR }) + expect(view).toHaveBeenLastCalledWith(expect.any(String), 'dsh-x', { command: 'pnpm', fields: ['name', 'version', 'description', 'dsh'], timeoutMs: 1000, registry: MIRROR }) // pnpm prints a refusal as JSON on stdout with nothing on stderr: it is read the same way, and the reason is its message. view.mockResolvedValueOnce({ exitCode: 1, stdout: '{\n "error": {\n "code": "ERR_PNPM_META_FETCH_FAIL",\n "message": "GET https://registry.npmjs.org/dsh-x: fetch failed"\n }\n}\n', stderr: '', timedOut: false }) answers(manifest) @@ -1525,7 +1525,7 @@ it('lists absent Official entries offline without resolving their packages or co for (const entry of OFFICIAL_ON_DEMAND_CATALOG) { expect(bundles.filter(bundle => bundle.name === entry.packageName)).toEqual([{ name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false, - removable: false, rows: [], overrides: [], meta: entry.meta, + removable: false, updatable: false, rows: [], overrides: [], meta: entry.meta, installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() }, }]) expect(resolve.mock.calls.some(([name]) => name === entry.packageName)).toBe(false) @@ -1685,3 +1685,276 @@ it.each(['absent', 'bundle', 'not-bundle', 'unreadable'])('keeps a missing sourc expect((await manager.listBundles()).find(item => item.name === name)?.error?.diagnostic).toContain('restore the checkout') expect((await manager.listBundles()).find(item => item.name === name)?.installTarget).toBeUndefined() }) + +/** A `pnpm view` stand-in answering by spec and printed fields. */ +function viewAnswers( + answer: (spec: string, fields: readonly string[], registry: string | null | undefined) => operations.PackageViewResult | object | string, +) { + const view = vi.spyOn(operations, 'viewProfilePackage').mockImplementation(async (_dir, spec, options) => { + const value = answer(spec, options.fields, options.registry) + if (typeof value === 'object' && 'exitCode' in value) return value + return { exitCode: 0, stdout: typeof value === 'string' ? value : JSON.stringify(value), stderr: '', timedOut: false } + }) + onTestFinished(() => { view.mockRestore() }) + return view +} + +const BUNDLE = { bundle: { patch: './cordis.patch.yml' } } + +/** The published `extra` versions the version tests offer; the installed copy is 1.0.0. */ +function extraRegistry(spec: string, fields: readonly string[]): object { + if (fields.includes('versions')) { + return { + versions: ['0.9.0', '1.0.0', '1.1.0', '1.2.0-beta.1', '1.2.0', '2.0.0', 'not-semver'], + time: { '1.1.0': '2026-09-01T00:00:00.000Z', '2.0.0': '2026-10-10T00:00:00.000Z' }, + 'dist-tags': { latest: '2.0.0', next: '1.2.0-beta.1' }, + } + } + const version = spec.slice('extra@'.length) + if (version === '2.0.0') return { name: 'extra', version, peerDependencies: { '@deepseek-ai/dsh': '>=999.0.0' }, dsh: BUNDLE } + if (version === '1.1.0') return { name: 'extra', version, deprecated: 'use 1.2.0', dsh: BUNDLE } + if (version === '1.2.0-beta.1') return { name: 'extra', version } + // An own `engines.dsh` decides compatibility alone, so its unmet peer range does not make this version incompatible. + if (version === '1.2.0') { + return { name: 'extra', version, engines: { dsh: '>=0.0.0-0' }, peerDependencies: { '@deepseek-ai/dsh': '>=999.0.0' }, dsh: BUNDLE } + } + return { name: 'extra', version, dsh: BUNDLE } +} + +it('lists newer registry versions with tags, times, deprecation, bundle declaration, and DSH compatibility', async () => { + const config = { inspectTimeoutMs: 1000, versionListLimit: 3, versionLookupConcurrency: 2 } + const { manager, dir } = await fixture(undefined, false, undefined, config) + const view = viewAnswers(extraRegistry) + const listed = await manager.listVersions('extra') + expect(listed).toEqual({ + status: 'listed', installed: '1.0.0', registry: null, attempts: [{ registry: null }], omitted: 1, + versions: [ + { + version: '2.0.0', publishedAt: '2026-10-10T00:00:00.000Z', tags: ['latest'], prerelease: false, bundle: true, + incompatible: { name: 'extra', version: '2.0.0', runtimeVersion: getDshRuntimeVersion(), requirements: { '@deepseek-ai/dsh': '>=999.0.0' } }, + }, + { version: '1.2.0', tags: [], prerelease: false, bundle: true }, + { version: '1.2.0-beta.1', tags: ['next'], prerelease: true, bundle: false }, + ], + }) + // The list itself, then one lookup per listed version on the registry that answered. + expect(view.mock.calls.map(call => call[1])).toEqual(expect.arrayContaining(['extra', 'extra@2.0.0', 'extra@1.2.0', 'extra@1.2.0-beta.1'])) + expect(view).toHaveBeenCalledTimes(4) + expect(view).toHaveBeenCalledWith(dir, 'extra', expect.objectContaining({ fields: ['versions', 'time', 'dist-tags'], timeoutMs: 1000 })) + expect(view).toHaveBeenCalledWith(dir, 'extra@1.2.0', expect.objectContaining({ fields: ['name', 'version', 'engines', 'peerDependencies', 'deprecated', 'dsh'], registry: null })) +}) + +it('lists versions from the next registry while one is unreachable, and refuses what no registry answered', async () => { + const { manager } = await fixture(undefined, false, undefined, { inspectTimeoutMs: 1000 }) + const unreachable = { exitCode: 1, stdout: '', stderr: 'ERR_PNPM_META_FETCH_FAIL GET https://registry.npmjs.org/extra: ETIMEDOUT\n', timedOut: false } + viewAnswers((spec, fields, registry) => registry === null ? unreachable + : fields.includes('versions') ? { versions: ['1.0.0', '1.1.0'], 'dist-tags': { latest: '1.1.0' } } : extraRegistry(spec, fields)) + // A requested registry is asked first. + expect(await manager.listVersions('extra', { registry: MIRROR })).toMatchObject({ status: 'listed', registry: MIRROR, attempts: [{ registry: MIRROR }] }) + expect(await manager.listVersions('extra')).toMatchObject({ + status: 'listed', registry: MIRROR, + attempts: [{ registry: null, problem: 'network', reason: unreachable.stderr.trim() }, { registry: MIRROR }], + versions: [{ version: '1.1.0', deprecated: 'use 1.2.0', tags: ['latest'] }], + }) + viewAnswers(() => unreachable) + expect(await manager.listVersions('extra')).toEqual({ + status: 'refused', problem: 'network', reason: unreachable.stderr.trim(), + attempts: [{ registry: null, problem: 'network', reason: unreachable.stderr.trim() }, { registry: MIRROR, problem: 'network', reason: unreachable.stderr.trim() }], + }) + // One version that cannot be read refuses the list, naming the registry that failed it. + viewAnswers((spec, fields) => spec === 'extra@1.1.0' ? { exitCode: 1, stdout: '', stderr: 'boom', timedOut: false } : extraRegistry(spec, fields)) + expect(await manager.listVersions('extra')).toMatchObject({ status: 'refused', problem: 'unknown', reason: 'boom', attempts: [{ registry: null, problem: 'unknown' }] }) + viewAnswers(() => ({ 'dist-tags': {} })) + expect(await manager.listVersions('extra')).toMatchObject({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no versions' }) + viewAnswers((spec, fields) => fields.includes('versions') ? { versions: '1.0.0' } : extraRegistry(spec, fields)) + expect(await manager.listVersions('extra')).toMatchObject({ status: 'listed', versions: [], omitted: 0 }) + viewAnswers((_spec, fields) => fields.includes('versions') ? { versions: ['1.1.0'] } : { name: 'extra', version: '1.1.0', peerDependencies: { '@deepseek-ai/dsh': 3 } }) + expect(await manager.listVersions('extra')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('must be a string') as string }) +}) + +it('refuses to list versions of a package not installed from a registry', async () => { + const { manager, dir, bundle } = await fixture() + const view = viewAnswers(extraRegistry) + const refusal = (reason: string) => ({ status: 'refused', problem: 'not-updatable', reason, attempts: [] }) + expect(await manager.listVersions('core')).toEqual(refusal('core is not a third-party package installed in this profile')) + expect(await manager.listVersions('absent')).toEqual(refusal('absent is not a third-party package installed in this profile')) + bundle('linked', []) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, linked: `link:${join(dir, 'node_modules', 'linked')}` } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + expect(await manager.listVersions('linked')).toEqual(refusal('linked is not installed from a registry under its own name')) + // An alias saves another package under the dependency's name; its versions are not the alias's. + const aliased = readProfileManifest('test', dir) + aliased.dependencies = { ...aliased.dependencies, linked: 'npm:other-plugin@1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(aliased)) + expect(await manager.listVersions('linked')).toEqual(refusal('linked is not installed from a registry under its own name')) + writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), JSON.stringify({ name: 'extra', version: 'nightly', dsh: BUNDLE })) + expect(await manager.listVersions('extra')).toEqual(refusal('extra declares no semver version')) + // A bundle carrying plugin management cannot be removed, so it is not offered for an update. + writeFileSync(join(dir, 'node_modules', 'extra', 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'pm', name: '@deepseek-ai/dsh-plugin-manager' }] }])) + expect((await manager.listBundles()).find(item => item.name === 'extra')).toMatchObject({ readOnlyReason: 'management-required', updatable: false }) + expect(await manager.listVersions('extra')).toEqual(refusal('extra carries plugin management and cannot be removed')) + expect(view).not.toHaveBeenCalled() +}) + +/** A pnpm stand-in that removes `extra` and installs the version an `add` names, unless `fails` names it. */ +function extraPnpm(dir: string, bundle: (name: string, rows: unknown[]) => void, fails: readonly string[] = []) { + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { + const manifest = readProfileManifest('test', dir) + const spec = String(args[1]) + if (args[0] === 'remove') { + delete manifest.dependencies?.extra + rmSync(join(dir, 'node_modules', 'extra'), { recursive: true, force: true }) + } else if (fails.includes(spec)) { + return { exitCode: 1, output: `ERR_PNPM_FETCH_404 GET ${spec}: Not Found - 404`, truncated: false, logPath: '/install.log', kind: 'not-found' as const } + } else { + const version = spec.slice('extra@'.length) + bundle('extra', [{ id: 'managed', name: './plugin.mjs' }]) + writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), JSON.stringify({ name: 'extra', version, dsh: BUNDLE })) + manifest.dependencies = { ...manifest.dependencies, extra: version } + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' } + }) + onTestFinished(() => { pnpm.mockRestore() }) + return pnpm +} + +it('updates a registry bundle by removing it and installing the exact newer version with its selection', async () => { + const { ctx, manager, dir, bundle } = await fixture() + viewAnswers(extraRegistry) + const pnpm = extraPnpm(dir, bundle) + const steps: string[] = [] + ctx.on('plugin-manager/update-state', ({ step }) => { steps.push(step) }) + const requestId = 'c2b8f1a4-6f0e-4b52-9a51-0a8e5f0b7d11' as PluginInstallRequestId + const updated = await manager.updateBundle('extra', '1.2.0', { registry: MIRROR, requestId }) + expect(updated).toMatchObject({ from: '1.0.0', to: '1.2.0', step: 'install', result: { application: 'applied', version: '1.2.0', enabled: true } }) + expect(updated.restore).toBeUndefined() + expect(steps).toEqual(['checking', 'removing', 'installing']) + expect(pnpm.mock.calls.map(call => call[1])).toEqual([['remove', 'extra'], ['add', 'extra@1.2.0', '--save-exact', `--registry=${MIRROR}`]]) + expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.2.0') + expect(ctx.get('managedProbe')).toBe(true) +}) + +it('installs the removed version again when the target fails to install', async () => { + const { manager, dir, bundle } = await fixture() + viewAnswers(extraRegistry) + const pnpm = extraPnpm(dir, bundle, ['extra@1.2.0']) + // The removed copy came from the mirror, so it is installed again from there. + await manager.updateBundle('extra', '1.1.0', { registry: MIRROR }) + pnpm.mockClear() + const updated = await manager.updateBundle('extra', '1.2.0') + expect(updated).toMatchObject({ + from: '1.1.0', to: '1.2.0', step: 'install', result: { application: 'failed', packageResult: { kind: 'not-found' } }, + restore: { application: 'applied', version: '1.1.0' }, + }) + expect(pnpm.mock.calls.map(call => call[1])).toEqual([ + // The update starts at the registry the package came from; a 404 there may be a mirror not yet synced, so pnpm's + // own registry is asked too before the installation gives up. + ['remove', 'extra', `--config.registry=${MIRROR}`], ['add', 'extra@1.2.0', '--save-exact', `--registry=${MIRROR}`], ['add', 'extra@1.2.0', '--save-exact'], + ['add', 'extra@1.1.0', '--save-exact', `--registry=${MIRROR}`], + ]) + expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.1.0') +}) + +it.each([ + ['cancelled', 'cancelled'], + ['rejected', 'rejected'], +] as const)('installs the removed version again when the target installation is %s', async (_name, outcome) => { + const { manager, dir, bundle } = await fixture() + viewAnswers(extraRegistry) + extraPnpm(dir, bundle) + const install = manager.installBundle.bind(manager) + const spy = vi.spyOn(manager, 'installBundle').mockImplementation(async (spec, options) => { + if (spec !== 'extra@1.2.0') return install(spec, options) + if (outcome === 'rejected') throw new Error('lock wait timed out') + return { changed: false, application: 'cancelled', stage: 'install', target: spec } + }) + onTestFinished(() => { spy.mockRestore() }) + const updated = await manager.updateBundle('extra', '1.2.0') + expect(updated.result.application).toBe(outcome === 'rejected' ? 'failed' : 'cancelled') + if (outcome === 'rejected') expect(updated.result.error).toEqual({ code: 'operation-error', diagnostic: 'lock wait timed out' }) + expect(updated.restore).toMatchObject({ application: 'applied', version: '1.0.0' }) + expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.0.0') +}) + +it('removes a target that installed but could not start before installing the removed version again', async () => { + const { manager, dir, bundle } = await fixture() + viewAnswers(extraRegistry) + const pnpm = extraPnpm(dir, bundle) + const install = manager.installBundle.bind(manager) + const spy = vi.spyOn(manager, 'installBundle').mockImplementation(async (spec, options) => { + const result = await install(spec, options) + // The target's files land, then its activation fails, as a plugin that throws on start does. + return spec === 'extra@1.2.0' ? { ...result, application: 'failed', stage: 'enable', error: { code: 'operation-error', diagnostic: 'cannot start' } } : result + }) + onTestFinished(() => { spy.mockRestore() }) + const updated = await manager.updateBundle('extra', '1.2.0') + expect(updated).toMatchObject({ result: { application: 'failed', stage: 'enable' }, restore: { application: 'applied', version: '1.0.0' } }) + expect(pnpm.mock.calls.map(call => call[1].slice(0, 2))).toEqual([['remove', 'extra'], ['add', 'extra@1.2.0'], ['remove', 'extra'], ['add', 'extra@1.0.0']]) + expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.0.0') +}) + +it('reports a removal that rejects as the update\'s last step', async () => { + const { manager, dir, bundle } = await fixture() + viewAnswers(extraRegistry) + const pnpm = extraPnpm(dir, bundle) + const spy = vi.spyOn(manager, 'removeBundle').mockRejectedValue(new Error('lock wait timed out')) + onTestFinished(() => { spy.mockRestore() }) + expect(await manager.updateBundle('extra', '1.2.0')).toMatchObject({ + step: 'remove', result: { application: 'failed', stage: 'remove', error: { code: 'operation-error', diagnostic: 'lock wait timed out' } }, + }) + expect(pnpm).not.toHaveBeenCalled() +}) + +it('reports a reinstallation that fails too, leaving the package uninstalled', async () => { + const { manager, dir, bundle } = await fixture() + viewAnswers(extraRegistry) + extraPnpm(dir, bundle, ['extra@1.2.0', 'extra@1.0.0']) + const updated = await manager.updateBundle('extra', '1.2.0') + expect(updated).toMatchObject({ step: 'install', result: { application: 'failed' }, restore: { application: 'failed' } }) + expect(readProfileManifest('test', dir).dependencies?.extra).toBeUndefined() +}) + +it('refuses an update before changing anything when the target is not a newer compatible bundle', async () => { + const { manager, dir, bundle } = await fixture() + viewAnswers((spec, fields) => spec === 'extra@3.0.0' ? { exitCode: 1, stdout: '', stderr: 'ERR_PNPM_NO_MATCHING_VERSION No matching version found for extra@3.0.0', timedOut: false } + : spec === 'extra@1.5.0' ? { name: 'extra', version: '1.5.0', peerDependencies: { '@deepseek-ai/dsh': 7 }, dsh: BUNDLE } + : extraRegistry(spec, fields)) + const pnpm = extraPnpm(dir, bundle) + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const refused = async (name: string, version: string) => (await manager.updateBundle(name, version)).result.error + expect(await refused('extra', '0.9.0')).toEqual({ code: 'invalid-spec', diagnostic: '0.9.0 is not a version newer than the installed 1.0.0' }) + expect(await refused('extra', 'latest')).toMatchObject({ code: 'invalid-spec' }) + expect(await refused('core', '2.0.0')).toEqual({ code: 'invalid-spec', diagnostic: 'core is not a third-party package installed in this profile' }) + expect(await refused('extra', '2.0.0')).toMatchObject({ code: 'incompatible-version', incompatible: [{ name: 'extra', version: '2.0.0' }] }) + expect(await refused('extra', '1.2.0-beta.1')).toEqual({ code: 'not-bundle' }) + expect(await refused('extra', '3.0.0')).toMatchObject({ code: 'operation-error', diagnostic: expect.stringContaining('No matching version') as string }) + expect(await refused('extra', '1.5.0')).toMatchObject({ code: 'operation-error', diagnostic: expect.stringContaining('must be a string') as string }) + expect(await manager.updateBundle('extra', '0.9.0')).toMatchObject({ from: '1.0.0', to: '0.9.0', step: 'check', result: { application: 'failed', changed: false, stage: 'install', target: 'extra' } }) + expect(pnpm).not.toHaveBeenCalled() + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) + +it('reports a removal the profile refuses as the update\'s last step', async () => { + const { manager, dir, bundle } = await fixture() + viewAnswers(extraRegistry) + const pnpm = extraPnpm(dir, bundle) + pnpm.mockResolvedValueOnce({ exitCode: 1, output: 'EBUSY', truncated: false, logPath: '/remove.log', kind: 'unknown' }) + expect(await manager.updateBundle('extra', '1.2.0')).toMatchObject({ step: 'remove', result: { application: 'failed', stage: 'remove' } }) + expect(pnpm).toHaveBeenCalledOnce() +}) + +it('installs over the running version without HMR and reports the restart', async () => { + const { ctx, manager, dir, bundle } = await fixture('startup') + viewAnswers(extraRegistry) + const pnpm = extraPnpm(dir, bundle) + const steps: string[] = [] + ctx.on('plugin-manager/update-state', ({ step }) => { steps.push(step) }) + const requestId = '5f7d1e9a-2b3c-4d5e-8f90-a1b2c3d4e5f6' as PluginInstallRequestId + expect(await manager.updateBundle('extra', '1.2.0', { requestId })).toMatchObject({ + step: 'install', result: { application: 'restart-required', restartReason: 'no-hmr', version: '1.2.0' }, + }) + expect(steps).toEqual(['checking', 'installing']) + expect(pnpm.mock.calls.map(call => call[1])).toEqual([['add', 'extra@1.2.0', '--save-exact']]) +}) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index 014c314e28..87b6977df3 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -8,6 +8,9 @@ import { expect, it, onTestFinished, vi } from 'vitest' import { getDshRuntimeVersion, initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { anchorPathSpec, readProfileRegistry, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts' +/** The manifest fields an inspection prints. */ +const VIEW_FIELDS = ['name', 'version', 'description', 'dsh'] as const + /** What execa resolves for a run that settled, including the buffered output the pre-install lookup is read for. */ interface FakeOutcome { exitCode: number | undefined @@ -936,26 +939,26 @@ it('asks the registry through pnpm view in the profile directory, without pnpm\' const { dir } = fixture() const answer = (value: object) => command.run.mockResolvedValueOnce(value as never) answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false }) - expect(await viewProfilePackage(dir, 'x@^1', { timeoutMs: 5 })).toEqual({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false }) + expect(await viewProfilePackage(dir, 'x@^1', { fields: VIEW_FIELDS, timeoutMs: 5 })).toEqual({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false }) expect(command.run).toHaveBeenLastCalledWith('pnpm', ['view', 'x@^1', 'name', 'version', 'description', 'dsh', '--json', '--config.fetch-retries=0'], expect.objectContaining({ cwd: dir, timeout: 5, reject: false, stdin: 'ignore' })) expect((command.run.mock.lastCall as unknown[])[2]).not.toHaveProperty('cancelSignal') // A registry asked by URL goes on the command line; null leaves the choice to pnpm's own configuration. answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false }) - await viewProfilePackage(dir, 'x', { timeoutMs: 5, registry: 'https://registry.npmmirror.com/' }) + await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5, registry: 'https://registry.npmmirror.com/' }) expect((command.run.mock.lastCall as unknown[])[1]).toEqual(['view', 'x', 'name', 'version', 'description', 'dsh', '--json', '--registry=https://registry.npmmirror.com/', '--config.fetch-retries=0']) answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false }) - await viewProfilePackage(dir, 'x', { timeoutMs: 5, registry: null }) + await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5, registry: null }) expect((command.run.mock.lastCall as unknown[])[1]).toEqual(['view', 'x', 'name', 'version', 'description', 'dsh', '--json', '--config.fetch-retries=0']) const signal = AbortSignal.abort() answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: true, isCanceled: false }) - expect(await viewProfilePackage(dir, 'x', { timeoutMs: 5, signal })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: true }) + expect(await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5, signal })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: true }) expect((command.run.mock.lastCall as unknown[])[2]).toMatchObject({ cancelSignal: signal }) answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: true }) - expect(await viewProfilePackage(dir, 'x', { command: 'node', timeoutMs: 5 })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: false }) + expect(await viewProfilePackage(dir, 'x', { command: 'node', fields: VIEW_FIELDS, timeoutMs: 5 })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: false }) expect((command.run.mock.lastCall as unknown[])[0]).toBe('node') answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: false, code: 'ENOENT', shortMessage: 'spawn pnpm ENOENT' }) - const missing = await viewProfilePackage(dir, 'x', { timeoutMs: 5 }) + const missing = await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5 }) expect(missing).toMatchObject({ exitCode: null, timedOut: false }) expect(missing.cause).toMatchObject({ message: 'spawn pnpm ENOENT', code: 'ENOENT' }) }) @@ -967,7 +970,7 @@ it('uses application-owned executable arguments and environment for package oper expect(command.run.mock.calls[0]).toEqual([runtime.command, [...runtime.args, 'add', resolve(context.cwd, 'extra')], expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown })]) command.run.mockResolvedValueOnce(Object.assign({ exitCode: 0, failed: false }, { stdout: '{}', stderr: '', timedOut: false })) - await viewProfilePackage(dir, 'example', { ...runtime, timeoutMs: 1000 }) + await viewProfilePackage(dir, 'example', { ...runtime, fields: VIEW_FIELDS, timeoutMs: 1000 }) expect(command.run).toHaveBeenLastCalledWith(runtime.command, [...runtime.args, 'view', 'example', 'name', 'version', 'description', 'dsh', '--json', '--config.fetch-retries=0'], expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown })) diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index c7981e2296..07c0000a20 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -86,7 +86,7 @@ it('exposes an absent Official bundle installation target without display metada const { manager, call } = await fixture() const offered: BundleInfo = { name: '@deepseek-ai/dsh-subagent-codex', official: true, availability: 'missing', - installed: false, enabled: false, optional: false, removable: false, rows: [], overrides: [], + installed: false, enabled: false, optional: false, removable: false, updatable: false, rows: [], overrides: [], installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@1.2.3', version: '1.2.3' }, meta: { title: 'Codex subagent' }, } diff --git a/packages/boot/plugin-manager/tests/versions.spec.ts b/packages/boot/plugin-manager/tests/versions.spec.ts new file mode 100644 index 0000000000..8e7b6d90d0 --- /dev/null +++ b/packages/boot/plugin-manager/tests/versions.spec.ts @@ -0,0 +1,20 @@ +/** Reading `pnpm view` answers about published versions. */ +import { expect, it } from 'vitest' +import { newerVersions, versionFacts } from '../src/versions.ts' + +it('reads one version\'s manifest, keeping the asked identity when the answer omits it', () => { + const bundle = { bundle: { patch: './p.yml' } } + expect(versionFacts({ name: 'x', version: '1.0.0', engines: { dsh: '>=1' }, peerDependencies: { a: '1' }, deprecated: 'old', dsh: bundle }, 'y', '2.0.0')).toEqual({ + manifest: { name: 'x', version: '1.0.0', engines: { dsh: '>=1' }, peerDependencies: { a: '1' } }, bundle: true, deprecated: 'old', + }) + expect(versionFacts({ dsh: { bundle: 'no' }, deprecated: '' }, 'y', '2.0.0')).toEqual({ manifest: { name: 'y', version: '2.0.0' }, bundle: false }) + expect(versionFacts('scalar', 'y', '2.0.0')).toEqual({ manifest: { name: 'y', version: '2.0.0' }, bundle: false }) +}) + +it('orders newer semver versions newest first and ignores time and tags that are not objects', () => { + expect(newerVersions({ versions: ['1.0.0', '1.10.0', '1.2.0', 7], time: 'x', 'dist-tags': [] }, '1.0.0')).toEqual([ + { version: '1.10.0', tags: [], prerelease: false }, + { version: '1.2.0', tags: [], prerelease: false }, + ]) + expect(() => newerVersions(null, '1.0.0')).toThrow('pnpm view answered no versions') +}) diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index ba181795a1..dac19ccf9e 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -64,6 +64,15 @@ }, { "path": "../../interaction/user-approval" + }, + { + "path": "../../util/plugin-detail" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../client/connection/tsconfig.host.json" } ] } diff --git a/packages/boot/plugin-market/README.i18n.yaml b/packages/boot/plugin-market/README.i18n.yaml index 8e54eca06a..a9b46ad8cd 100644 --- a/packages/boot/plugin-market/README.i18n.yaml +++ b/packages/boot/plugin-market/README.i18n.yaml @@ -9,17 +9,20 @@ en: ee5478f5717295df zh: 75a03a6db791fc6b /deepseek-ai-dsh-plugin-market/summary: - en: 05b6a148b03fa0a4 - zh: e5568747e1cf1d16 + en: 5fc3f5439344cb9a + zh: 73e549c0b21b3843 /deepseek-ai-dsh-plugin-market/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-plugin-market/use-this-package: - en: 880da6f2249be5dc - zh: af79801b129d774c + en: 0553ab42ee4d83ff + zh: 4cc8163884357247 +/deepseek-ai-dsh-plugin-market/use-this-package/plugin-detail-documents: + en: 0e15de273c9acaae + zh: 4fd93c92b71d4398 /deepseek-ai-dsh-plugin-market/understand-the-implementation: - en: fe70456b69339c7a - zh: 46e53647850af839 + en: 678705f57d986377 + zh: 68d090edfe04acde /deepseek-ai-dsh-plugin-market/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -36,8 +39,8 @@ en: 02cf6033092eb236 zh: cb928bfa6d39a7f7 /deepseek-ai-dsh-plugin-market/known-limitations-and-deferred-work: - en: 2097c5644d5b6d85 - zh: 1deb4c97e7efbfc6 + en: 427d9c58ee09bea4 + zh: ec1c2bade3b56014 /deepseek-ai-dsh-plugin-market/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/boot/plugin-market/README.md b/packages/boot/plugin-market/README.md index 4e191bf8c0..e805dcb2e4 100644 --- a/packages/boot/plugin-market/README.md +++ b/packages/boot/plugin-market/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`pluginMarket` reads the plugin market indexes the user configures, validates them with [`dsh-plugin-market-index`](../../util/plugin-market-index/README.md), and annotates every entry with the profile's installation status, DSH compatibility, and the exact arguments that install it. The Web [Discover tab](../../client/ui-plugin-market/README.md) and the `plugin_market` agent tool read through it; installation itself goes through [`pluginManager.installBundle`](../plugin-manager/README.md). The format and its rationale live in the [index format Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md); the Host and Web design lives in the [Discover Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md). +`pluginMarket` reads the plugin market indexes the user configures, validates them with [`dsh-plugin-market-index`](../../util/plugin-market-index/README.md), and annotates every entry with the profile's installation status, DSH compatibility, and the exact arguments that install it. It also reads each market entry's long description, a Markdown document with images, for Discover entry pages. The Web [Discover tab](../../client/ui-plugin-market/README.md) and the `plugin_market` agent tool read through it; installation itself goes through [`pluginManager.installBundle`](../plugin-manager/README.md). The format and its rationale live in the [index format Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md); the Host and Web design lives in the [Discover Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md). ## Table of Contents @@ -31,7 +31,15 @@ The optional [custom plugin markets bundle](../../experimental/plugin-market-bun | `indexes` | `[]` | Index URLs in display order. A usable URL is in canonical form and uses `http:` or `https:`, without credentials or fragment. The field is volatile: Discover, Settings, and `setIndexes` change it without remounting this service or `plugin-manager`. | | `fetchTimeoutMs` | `15000` | Deadline for one index fetch, including its body. | | `maxBytes` | `134217728` | Largest accepted index body. | -| `cacheTtlMs` | `600000` | Lifetime of a successfully read document. | +| `cacheTtlMs` | `600000` | Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. | +| `detailMaxBytes` | `1048576` | Largest detail document, and largest locale file read for one. | +| `detailImageMaxBytes` | `5242880` | Largest image served beside a detail document. | +| `detailRepositoryMaxBytes` | `52428800` | Largest amount one Git command adds to a partial clone; a repository whose fetch adds more is not read. | +| `detailCacheMaxBytes` | `524288000` | Largest total size of the partial clones; least recently used ones are deleted beyond it after each fetch. | +| `detailCacheDir` | `.plugin-market/repositories` | Directory of the partial clones, relative to the profile directory; required, as an absolute path, when the service runs outside a dsh profile. | +| `detailGitTimeoutMs` | `60000` | Deadline of one Git command of the clone fallback. | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first, and CDNs this list omits are not asked. | +| `detailGitlabHosts` | `['gitlab.com']` | Hosts whose GitLab repository files API serves public repositories, compared without letter case. | Settings and profile files can store any http(s) string, so the service validates each URL when it lists or reads it. `listIndexes` reports an unusable URL with its refusal, `search` and `entry` refuse it, and no request is sent to it; a repeated URL is listed once. @@ -40,6 +48,7 @@ The Remote methods are: - `listIndexes()` returns each configured URL with the title and description of its last successful read and, when the last read failed, its error. It never fetches. - `setIndexes(urls)` normalizes and validates each URL not already configured, keeps configured ones as written, refuses a repeated URL, and writes it through `settings.update` to this service's profile entry. It fails with `plugin-market/settings-unavailable` without Settings or a profile entry. The committed list is announced by `plugin-market/changed`. - `search(url, query)` accepts only a configured URL, so the service never fetches an arbitrary address. It returns one page of the plugins that match `query`, with the index title, description, and declarations, the document's `fetchedAt`, the `total` number of matches, and the `nextOffset`; each plugin carries its `defaultVersion` and `installation`, and each version its `install` and `compatibility`. +- `readEntryDetail(url, name, version, language, { refresh?, npmFileSources? })` reads the long description of one listed version of a configured index's entry from that version's source: the npm file CDNs for a version without `registry` or with the npm or npmmirror registry, or its pinned Git commit and `path`. An npm version from another registry has no readable package files. The service method `entry(url, name)`, which is not a Remote method, returns one plugin of a configured index in the same form, or `undefined` when the index does not list it. @@ -57,6 +66,18 @@ A query selects plugins with every one of its conditions: Text matches the package name, author, keywords, and every language of the title, description, and category and label titles. It is split into runs of letters, marks, and digits; a run of Chinese, Japanese, or Korean characters becomes its overlapping character pairs and its last character, so text written without spaces matches without a dictionary. Words are compared after NFKC normalization and without case. Every query word must match an indexed word as a prefix or, from four characters, within an edit distance of one fifth of its length rounded to the nearest integer, at most 2. Name and title matches weigh three times, and keyword matches twice, a match elsewhere. +### Plugin detail documents + +`readEntryDetail` reads all three steps of the [document order](../../util/plugin-detail/README.md#document-order): the locale `meta.detail` file and the README from the entry version's source, then the declared repository's README. For an npm version the README is the one npm shows for it. A Git entry whose `repository` is its own source directory skips the repository step. A malformed locale file or `meta.detail`, or a named file that does not exist, fails the read instead of moving to the next step. + +Without any, the result is `{ status: 'none' }`. A found document carries its `origin` (`locale`, `readme`, or `repository`), its Markdown without a leading YAML front matter block, and a `document` id. Results, and the `too-large`, `denied`, and `invalid` failures that reading again would repeat, are cached for `cacheTtlMs`; `refresh` reads again instead, and concurrent reads share one read. A `document` id stays valid while a read returns it or an image request names it at least once every `cacheTtlMs`; an unused id is forgotten, and its images answer 404 until the document is read again. + +Repository files are read through GitHub's raw file host for `github.com` and the GitLab repository files API for `detailGitlabHosts`. API requests follow redirects within their origin. When an API answers 401, 403, or 404, a request to the repository's smart HTTP endpoint without any credentials decides whether the repository is public; a public repository's miss means the file does not exist. A repository that needs credentials, and a repository on a host without such an API, is read from a partial clone over https, http, or ssh. Git uses the user's configuration, credential helpers, and `url..insteadOf` rules without prompting, so an https URL can be routed to ssh; ssh runs in batch mode unless `GIT_SSH` or `GIT_SSH_COMMAND` is set. + +`GET /api/plugin-market/detail-file?document=&path=` serves an image beside a read document to authenticated owner connections. The destination is relative to the document, or to the package or repository root when it starts with `/`; its query and fragment are ignored. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside that root are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and a private cache for `cacheTtlMs`. A missing file, unknown document, or other path answers 404; the failures below answer 413, 502, 403, 503, and 400; failure responses are not cached. + +A source that exists but cannot be read fails with `plugin-market/detail-unavailable` and one `reason`: `too-large` for a document, image, or repository over its limit; `network` when a source cannot be reached or times out; `denied` when Git refuses the repository with the user's credentials; `git-unavailable` when a repository must be cloned and Git cannot run; `invalid` for a malformed locale file or `meta.detail`, or a named file that does not exist. An unlisted entry or version fails with `plugin-market/unknown-plugin`; an invalid language fails with `plugin-market/invalid-query`. A Git that cannot start and a local Git command that fails, such as on a full disk, fail the read without a detail reason and are not cached. + Failures use `plugin-market/invalid-query`, `plugin-market/invalid-index-url`, `plugin-market/unknown-index`, `plugin-market/fetch-failed`, and `plugin-market/invalid-index`; the last carries the JSON path of the offending value. The `plugin_market` tool lists the configured indexes, pages the plugins of one index in index order or, with a `query`, by best match, or pages one plugin's versions. It requires no approval because it reads only the sources the user configured and changes nothing; each version it returns carries the `target` and `registry` to pass to `plugin_manager`'s `install_bundle`, which keeps its own approval. The tool cannot change index sources. Creator mode keeps its row beside `plugin_manager`, and the row registers the tool only while `pluginMarket` is mounted; other presets keep it disabled. @@ -74,6 +95,8 @@ Filtering and ordering read only the document; installation status and compatibi Compatibility is computed per version with `evaluatePluginCompatibility`, the version's `engines.dsh`, and the profile's version exemptions, the same check installation applies. A plugin's default version is its highest compatible version without a prerelease tag, or its highest compatible prerelease when it lists no version without one; a plugin with no compatible version has none. Installation status comes from `pluginManager.listBundles()` by package name, measured against the default version, or the highest version without one: `not-installed`, also for an on-demand catalog entry the profile has not installed; `installed` at that version; `update-available` when that version is greater; `different-version` when the installed version is unknown, not valid SemVer, or greater; and `provided` for a bundle the DSH installation ships. +Entry detail reads share one reader that the service owns; document selection and the npm file CDNs come from [`dsh-plugin-detail`](../../util/plugin-detail/README.md). Each partial clone is a bare repository under `detailCacheDir`, named by a hash of its URL, initialized in a staging directory and renamed into place, and fetched with `--depth 1 --filter=blob:none` so only commits and trees arrive; Git downloads a file's contents when it is read. Commands run without inherited `GIT_*` variables other than the ssh program, with hooks, the file-system monitor, submodule recursion, and every transport but https, http, and ssh disabled, and no working tree is created. The directory size is checked every 200 ms while Git downloads; growth past `detailRepositoryMaxBytes` within one command stops Git, and output keeps a file's final newline byte. A file read stops once its contents pass the read's limit, and an image read completes even when the browser abandons the request, so leaving a page never interrupts a clone. A ref under `refs/dsh/` records each fetched commit, so a commit is never fetched lazily without the size check. Operations on one repository run one at a time, also across processes that share `detailCacheDir`, such as Web and Desktop on one profile, through a lock file beside the repository; eviction deletes a repository only after them, enforces `detailCacheMaxBytes` after every fetch and every file read, and deletes the repository in use last, after its operation, when it alone exceeds the total; one that fails or is cancelled, for any reason but a file over its read's limit, deletes the repository, so the next read starts from a fresh clone. Eviction also removes staging directories older than `detailGitTimeoutMs`. Disposal stops Git and waits for it. + `marketInstallTarget` builds install arguments: `name@version` with the version's registry for npm, and `git+.git#` with `&path:/` for a package directory in Git. ----- @@ -104,6 +127,11 @@ Append-only: results enter the conversation as ordinary tool results and do not - **Last write wins** — `setIndexes` replaces the whole list, so two windows editing sources at the same time can overwrite each other's change. - **Redirects refused** — an index served only behind a redirect must be configured at its final URL. - **Whole-document reads** — the Host downloads and keeps each index up to `maxBytes` and builds its text index in memory; a synthetic index of 20,000 plugins took about 0.8 s of build time and 130 MiB of heap. Parsing runs in a Worker, but receiving the validated index still blocks the Host event loop while the clone is deserialized: about 35 ms for a synthetic 7.6 MiB index of 20,000 plugins and about 190 ms for 38 MiB and 100,000 plugins. Larger markets need a server-side query protocol. +- **Default-branch READMEs** — a repository README is read at the default branch, which can differ from the listed version; npm records no commit the CDNs expose. +- **Private npm registries** — the public CDNs do not serve packages of other registries, so only the entry's repository can provide such a version's document. +- **Fetching an unadvertised commit** — a pinned Git entry is fetched by commit id, which the server must allow (`uploadpack.allowReachableSHA1InWant` or `allowAnySHA1InWant`); GitHub and GitLab allow it, and a server that refuses reports `denied`. +- **Exact README names through APIs** — the single-file APIs of public repositories are asked for `README.md`, `readme.md`, `Readme.md`, `README.markdown`, and `README` only; another letter case, such as `README.MD`, is found only in a clone. +- **No credentials for APIs** — GitHub and GitLab APIs are read anonymously; private repositories always use the clone fallback, which needs the user's Git credentials for the host. - **Pages can change** — `fetchedAt` pins pages only while the document is cached; after another read replaces it, a reader must start again from the first page. diff --git a/packages/boot/plugin-market/README.zh.md b/packages/boot/plugin-market/README.zh.md index 6d590412b0..31dae179db 100644 --- a/packages/boot/plugin-market/README.zh.md +++ b/packages/boot/plugin-market/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`pluginMarket` 读取用户配置的插件市场索引,用 [`dsh-plugin-market-index`](../../util/plugin-market-index/README.zh.md) 校验,并为每个条目标注 profile 中的安装状态、DSH 兼容性以及安装它所需的精确参数。Web 的[发现标签页](../../client/ui-plugin-market/README.zh.md)和 `plugin_market` agent 工具通过它读取;安装本身经由 [`pluginManager.installBundle`](../plugin-manager/README.zh.md) 进行。格式及其理由见[索引格式 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md);Host 与 Web 的设计见[发现 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md)。 +`pluginMarket` 读取用户配置的插件市场索引,用 [`dsh-plugin-market-index`](../../util/plugin-market-index/README.zh.md) 校验,并为每个条目标注 profile 中的安装状态、DSH 兼容性以及安装它所需的精确参数。它还为发现区的条目页面读取每个市场条目的详细介绍,即一份带图片的 Markdown 文档。Web 的[发现标签页](../../client/ui-plugin-market/README.zh.md)和 `plugin_market` agent 工具通过它读取;安装本身经由 [`pluginManager.installBundle`](../plugin-manager/README.zh.md) 进行。格式及其理由见[索引格式 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md);Host 与 Web 的设计见[发现 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md)。 ## 目录 @@ -31,7 +31,15 @@ kind: "package-reference" | `indexes` | `[]` | 按显示顺序排列的索引 URL。可用的 URL 采用规范形式,使用 `http:` 或 `https:`,不含凭据或片段。该字段是 volatile 字段:发现区、设置和 `setIndexes` 修改它时,不会重新挂载本服务或 `plugin-manager`。 | | `fetchTimeoutMs` | `15000` | 单次索引获取(包括响应体)的时限。 | | `maxBytes` | `134217728` | 接受的最大索引响应体。 | -| `cacheTtlMs` | `600000` | 成功读取的文档的缓存时长。 | +| `cacheTtlMs` | `600000` | 成功读取的索引文档、详细介绍的读取结果以及仓库默认分支提交的缓存时长。 | +| `detailMaxBytes` | `1048576` | 详细介绍文档的最大大小,以及为读取它而读取的 locale 文件的最大大小。 | +| `detailImageMaxBytes` | `5242880` | 与详细介绍文档一同提供的图片的最大大小。 | +| `detailRepositoryMaxBytes` | `52428800` | 单条 Git 命令向一个部分克隆增加的最大数据量;获取时增加的数据量超过该值的仓库不被读取。 | +| `detailCacheMaxBytes` | `524288000` | 所有部分克隆的最大总大小;每次获取后,超出时删除最近最少使用的部分克隆。 | +| `detailCacheDir` | `.plugin-market/repositories` | 部分克隆所在的目录,相对于 profile 目录;服务在 dsh profile 之外运行时必须以绝对路径配置。 | +| `detailGitTimeoutMs` | `60000` | 克隆回退方式中单条 Git 命令的时限。 | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | 按顺序读取的 npm 文件 CDN;一次读取的 `npmFileSources` 把其中列出的 CDN 移到最前,本列表未列出的 CDN 不会被请求。 | +| `detailGitlabHosts` | `['gitlab.com']` | 通过 GitLab 仓库文件 API 提供公开仓库的主机,比较时不区分大小写。 | 设置和 profile 文件可以存储任意 http(s) 字符串,因此服务在列出或读取每个 URL 时校验它。`listIndexes` 报告不可用的 URL 及其拒绝原因,`search` 和 `entry` 拒绝该 URL,且不会向它发送请求;重复的 URL 只列出一次。 @@ -40,6 +48,7 @@ Remote 方法如下: - `listIndexes()` 返回每个已配置 URL,以及其最近一次成功读取得到的标题和描述;最近一次读取失败时,还返回其错误。它从不发起获取。 - `setIndexes(urls)` 规范化并校验每个尚未配置的 URL,已配置的 URL 保持原样,拒绝重复的 URL,并通过 `settings.update` 把列表写入本服务的 profile 条目。没有 Settings 或 profile 条目时,以 `plugin-market/settings-unavailable` 失败。提交后的列表通过 `plugin-market/changed` 通告。 - `search(url, query)` 只接受已配置的 URL,因此服务从不获取任意地址。它返回匹配 `query` 的一页插件,并附带索引的标题、描述和声明、该文档的 `fetchedAt`、匹配总数 `total` 和 `nextOffset`;每个插件携带 `defaultVersion` 和 `installation`,每个版本携带 `install` 和 `compatibility`。 +- `readEntryDetail(url, name, version, language, { refresh?, npmFileSources? })` 从已配置索引中某个条目的一个已列出版本的来源读取该版本的详细介绍:没有 `registry` 或使用 npm 或 npmmirror 注册表的版本从 npm 文件 CDN 读取,Git 版本从其固定的 Git 提交和 `path` 读取。来自其他注册表的 npm 版本没有可读取的包文件。 服务方法 `entry(url, name)` 不是 Remote 方法,它以相同形式返回已配置索引中的一个插件;索引未列出该插件时返回 `undefined`。 @@ -57,6 +66,19 @@ Remote 方法如下: 文本匹配包名、作者、关键词,以及标题、描述、分类标题和标签标题的所有语言。文本被拆分为由字母、组合符号和数字构成的连续片段;由中文、日文或韩文字符构成的片段变为相邻的两字组合及其最后一个字符,因此不带空格书写的文本无需词典也能匹配。词在 NFKC 规范化后不区分大小写地比较。查询中的每个词都必须匹配某个已索引的词,可以是前缀,或者在词长至少四个字符时,编辑距离不超过其长度的五分之一(四舍五入),且最多为 2。名称和标题匹配的权重是其他位置的三倍,关键词匹配是两倍。 + +### 插件详细介绍文档 + +`readEntryDetail` 读取[文档顺序](../../util/plugin-detail/README.zh.md#document-order)的全部三步:先从条目版本的来源读取 locale `meta.detail` 文件和 README,再读取所声明仓库的 README。对 npm 版本,README 即 npm 为该版本显示的 README。`repository` 就是自身来源目录的 Git 条目跳过仓库这一步。locale 文件或 `meta.detail` 格式错误,或指定的文件不存在时,读取失败,而不是进入下一步。 + +三者都没有时,结果为 `{ status: 'none' }`。找到的文档携带其 `origin`(`locale`、`readme` 或 `repository`)、去掉开头 YAML front matter 块后的 Markdown 内容和 `document` id。结果,以及再次读取也会重复出现的 `too-large`、`denied` 和 `invalid` 失败,缓存 `cacheTtlMs`;传入 `refresh` 时改为重新读取;并发读取共享一次读取。只要每个 `cacheTtlMs` 内至少有一次读取返回某个 `document` id 或有一次图片请求引用它,该 id 就保持有效;未使用的 id 会被遗忘,在再次读取该文档之前,其图片请求返回 404。 + +`github.com` 上的仓库文件通过 GitHub 的原始文件主机读取,`detailGitlabHosts` 中主机上的仓库文件通过 GitLab 仓库文件 API 读取。API 请求跟随同一 origin 内的重定向。API 返回 401、403 或 404 时,由一次不带任何凭据、发往该仓库 smart HTTP 端点的请求判断仓库是否公开;公开仓库中未找到文件即表示该文件不存在。需要凭据的仓库,以及所在主机没有此类 API 的仓库,通过 https、http 或 ssh 从部分克隆中读取。Git 使用用户的配置、凭据助手和 `url..insteadOf` 规则,且不提示输入,因此 https URL 可能被改为通过 ssh 访问;除非设置了 `GIT_SSH` 或 `GIT_SSH_COMMAND`,ssh 以批处理模式运行。 + +`GET /api/plugin-market/detail-file?document=&path=` 向已通过认证的 owner 连接提供已读取文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包或仓库的根目录;路径中的查询和片段被忽略。只提供该根目录内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和时长为 `cacheTtlMs` 的私有缓存。文件不存在、文档未知或其他路径返回 404;下文所列的失败依次返回 413、502、403、503 和 400;失败响应不被缓存。 + +存在但无法读取的来源以 `plugin-market/detail-unavailable` 失败,并携带一个 `reason`:文档、图片或仓库超过其大小限制时为 `too-large`;来源无法访问或超时时为 `network`;Git 使用用户的凭据仍被仓库拒绝时为 `denied`;仓库必须克隆而 Git 无法运行时为 `git-unavailable`;locale 文件或 `meta.detail` 格式错误,或指定的文件不存在时为 `invalid`。未列出的条目或版本以 `plugin-market/unknown-plugin` 失败;无效的语言以 `plugin-market/invalid-query` 失败。Git 无法启动,以及本地 Git 命令失败(例如磁盘已满)时,读取失败且不带详细介绍的失败原因,也不被缓存。 + 失败使用 `plugin-market/invalid-query`、`plugin-market/invalid-index-url`、`plugin-market/unknown-index`、`plugin-market/fetch-failed` 和 `plugin-market/invalid-index`;最后一种携带违规值的 JSON 路径。 `plugin_market` 工具列出已配置的索引,按索引顺序分页列出某个索引的插件,或在给出 `query` 时按最佳匹配分页列出,或分页列出某个插件的版本。它不需要批准,因为它只读取用户配置的源且不做任何改动;它返回的每个版本都携带要传给 `plugin_manager` 的 `install_bundle` 的 `target` 和 `registry`,`install_bundle` 仍保留自己的批准要求。该工具不能修改索引源。Creator 模式在 `plugin_manager` 旁边保留它的行,该行只在 `pluginMarket` 已挂载时注册工具;其他预设保持禁用。 @@ -74,6 +96,8 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒 兼容性按版本计算:使用 `evaluatePluginCompatibility`,传入该版本的 `engines.dsh` 和 profile 的版本豁免,与安装时执行的检查相同。插件的默认版本是其不带先行版本号的最高兼容版本;插件完全没有此类版本时,使用其最高兼容的先行版本;没有兼容版本的插件没有默认版本。安装状态按包名根据 `pluginManager.listBundles()` 计算,以默认版本为准,没有默认版本时以最高版本为准:`not-installed`(包括当前 profile 尚未安装的按需目录条目);版本相同时为 `installed`;该版本更高时为 `update-available`;已安装版本未知、不是有效的 SemVer 或更高时为 `different-version`;DSH 安装随附的组合包(bundle)为 `provided`。 +条目详细介绍的读取共用服务拥有的一个读取器;文档选择和 npm 文件 CDN 来自 [`dsh-plugin-detail`](../../util/plugin-detail/README.zh.md)。每个部分克隆是 `detailCacheDir` 下的一个裸仓库,以其 URL 的哈希命名,先在暂存目录中初始化,再重命名到目标位置,并使用 `--depth 1 --filter=blob:none` 获取,因此只下载提交和树对象;读取文件时 Git 才下载其内容。命令运行时不继承除 ssh 程序之外的 `GIT_*` 变量,禁用 hook、文件系统监视器、子模块递归,以及 https、http 和 ssh 之外的所有传输协议,且不创建工作树。Git 下载期间每 200 毫秒检查一次目录大小;单条命令执行期间增长超过 `detailRepositoryMaxBytes` 时停止 Git,且输出保留文件末尾的换行字节。文件内容超过本次读取的限制时,读取即停止;浏览器放弃请求时,图片读取仍会完成,因此离开页面不会中断克隆。`refs/dsh/` 下的 ref 记录每个已获取的提交,因此不会在没有大小检查的情况下延迟获取提交。对同一仓库的操作逐个执行,共享 `detailCacheDir` 的多个进程(例如同一 profile 上的 Web 和 Desktop)之间也是如此,由仓库旁的一个锁文件实现;淘汰只在这些操作结束后删除该仓库,并在每次获取和每次文件读取后执行 `detailCacheMaxBytes` 总量限制;正在使用的仓库单独超过该总量时,淘汰最后删除它,并等到其操作结束后再删除;除文件超过本次读取的限制外,任何原因导致操作失败或被取消时,都删除该仓库,使下一次读取从新的克隆开始。淘汰时还会删除存在时间超过 `detailGitTimeoutMs` 的暂存目录。销毁时停止 Git 并等待其退出。 + `marketInstallTarget` 构建安装参数:npm 为 `name@version` 加该版本的 registry,Git 为 `git+.git#`,包位于子目录时追加 `&path:/`。 ----- @@ -104,6 +128,11 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒 - **后写入者覆盖**——`setIndexes` 会替换整个列表,因此两个窗口同时编辑索引源时,可能互相覆盖对方的修改。 - **拒绝重定向**——只能经重定向访问的索引必须以其最终 URL 配置。 - **读取整份文档**——Host 下载并保留每个不超过 `maxBytes` 的索引,并在内存中构建其文本索引;一个包含 20,000 个插件的合成索引构建用时约 0.8 秒,占用约 130 MiB 堆内存。解析在 Worker 中运行,但接收校验后的索引时,反序列化 clone 仍会阻塞 Host 事件循环:包含 20,000 个插件的 7.6 MiB 合成索引约 35 毫秒,包含 100,000 个插件的 38 MiB 索引约 190 毫秒。更大的市场需要服务端查询协议。 +- **默认分支的 README**——仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致;CDN 公开的 npm 数据不记录提交。 +- **私有 npm 注册表**——公共 CDN 不提供其他注册表的包,因此此类版本的文档只能来自条目的仓库。 +- **获取未公布的提交**——固定版本的 Git 条目按提交 id 获取,这需要服务器允许(`uploadpack.allowReachableSHA1InWant` 或 `allowAnySHA1InWant`);GitHub 和 GitLab 允许这样做,拒绝的服务器报告 `denied`。 +- **API 只查询精确的 README 文件名**——公开仓库的单文件 API 只查询 `README.md`、`readme.md`、`Readme.md`、`README.markdown` 和 `README`;其他大小写形式(例如 `README.MD`)只能在克隆中找到。 +- **API 不使用凭据**——GitHub 和 GitLab API 以匿名方式读取;私有仓库总是使用克隆回退方式,这要求用户拥有该主机的 Git 凭据。 - **分页可能变化**——`fetchedAt` 只在文档仍被缓存时固定分页;另一次读取替换文档后,读取方必须从第一页重新开始。 diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index 644ed4be73..b10dc50c84 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -50,11 +50,16 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", + "@deepseek-ai/dsh-plugin-detail": "workspace:*", "@deepseek-ai/dsh-plugin-market-index": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", + "execa": "^10.0.1", + "hosted-git-info": "^9.0.3", "minisearch": "^7.2.0", "semver": "^7.8.5", "zod": "^4.4.3" @@ -64,6 +69,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-plugin-manager": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, @@ -77,13 +83,16 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-config-editor": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@types/hosted-git-info": "^3.0.5", "@types/semver": "^7.8.0" } } diff --git a/packages/boot/plugin-market/src/detail-files.ts b/packages/boot/plugin-market/src/detail-files.ts new file mode 100644 index 0000000000..c9c5faa9a7 --- /dev/null +++ b/packages/boot/plugin-market/src/detail-files.ts @@ -0,0 +1,79 @@ +/** + * Git repository files for plugin detail documents, through GitHub or GitLab single-file APIs with a cached partial + * clone as the fallback for repositories those APIs cannot serve anonymously. + */ +import { + DetailFailure, encodePath, fetchBody, README_NAMES, readmeByName, type DetailFiles, type ReadFile, type RemoteFileOptions, +} from '@deepseek-ai/dsh-plugin-detail' +import type { RepositoryCache } from './repository-cache.ts' + +/** A repository as single-file APIs and Git address it. */ +export interface RepositoryRef { + /** Lower-case host name, without port. */ + readonly host: string + /** The project path below the host, such as `owner/repo` or `group/subgroup/project`, without `.git`. */ + readonly project: string + /** URL Git fetches from: `https:///.git`, or the configured http(s) URL as written for other forms. */ + readonly url: string +} + +/** Which single-file API a host offers anonymously. */ +type FileApi = 'github' | 'gitlab' | undefined + +/** Single-file API settings and the clone fallback of repository sources. */ +export interface RepositoryFileOptions extends RemoteFileOptions { + /** Lower-case hosts serving the GitLab repository files API; `github.com` always uses GitHub's raw file host. */ + readonly gitlabHosts: readonly string[] + /** The clone cache's reads. */ + readonly cache: Pick +} + +function apiUrl(api: Exclude, repository: RepositoryRef, ref: string, path: string): string { + return api === 'github' + ? `https://raw.githubusercontent.com/${repository.project}/${encodeURIComponent(ref)}/${encodePath(path)}` + // The repository URL's origin keeps a self-hosted GitLab's scheme and port. + : `${new URL(repository.url).origin}/api/v4/projects/${encodeURIComponent(repository.project)}/repository/files/${encodeURIComponent(path)}/raw?ref=${encodeURIComponent(ref)}` +} + +/** + * Files of one repository at a commit, or at its default branch. Hosts with a single-file API are read through it; a + * missing answer is confirmed with an anonymous request to the repository's smart HTTP endpoint, and a repository that + * needs credentials, or a host without such an API, is read from the cached partial clone. + * @param repository - the repository. + * @param commit - a full commit id, or undefined for the default branch. + * @param options - API hosts, deadline, lifetime, and the clone cache. + * @returns the files. + */ +export function repositoryFiles(repository: RepositoryRef, commit: string | undefined, options: RepositoryFileOptions): DetailFiles { + const api: FileApi = repository.host === 'github.com' ? 'github' : options.gitlabHosts.includes(repository.host) ? 'gitlab' : undefined + const ref = commit ?? 'HEAD' + /** An API read: the bytes, or undefined for an answer that does not distinguish a missing file from a private repository. */ + const fromApi = async (path: string, maxBytes: number, signal: AbortSignal, via: Exclude) => { + const body = await fetchBody(apiUrl(via, repository, ref, path), maxBytes, signal, options) + if (body.ok) return body.bytes + if (body.status !== 404 && body.status !== 401 && body.status !== 403) { + throw new DetailFailure('network', `${repository.url}: ${path} answered HTTP ${String(body.status)}`) + } + return undefined + } + return { + key: `git:${repository.url}#${ref}`, + async read(path, maxBytes, signal) { + if (api !== undefined) { + const bytes = await fromApi(path, maxBytes, signal, api) + if (bytes !== undefined || await options.cache.isPublic(repository.url)) return bytes + } + return await options.cache.read(repository.url, commit, path, maxBytes, signal) + }, + async readme(dir, maxBytes, signal) { + if (api !== undefined) { + const read: ReadFile = (path, max, readSignal) => fromApi(path, max, readSignal, api) + // The first name settles most lookups; the others are worth asking only where a miss is authoritative. + const found = await readmeByName(read, dir, maxBytes, signal, README_NAMES.slice(0, 1)) + if (found !== undefined) return found + if (await options.cache.isPublic(repository.url)) return await readmeByName(read, dir, maxBytes, signal, README_NAMES.slice(1)) + } + return await options.cache.readme(repository.url, commit, dir, maxBytes, signal) + }, + } +} diff --git a/packages/boot/plugin-market/src/detail.ts b/packages/boot/plugin-market/src/detail.ts new file mode 100644 index 0000000000..127dc2896d --- /dev/null +++ b/packages/boot/plugin-market/src/detail.ts @@ -0,0 +1,221 @@ +/** + * Plugin market entry detail documents: the package document {@link readPackageDocument} selects from the entry + * version's source, otherwise the README of the repository directory the entry or its package declares; cached reads + * and the images beside a read document. + */ +import { createHash } from 'node:crypto' +import { posix } from 'node:path' +import { brandString } from '@deepseek-ai/dsh-brand' +import { DetailFailure, detailImage, joinPath, markdownOf, readPackageDocument, type DetailFiles } from '@deepseek-ai/dsh-plugin-detail' +import hostedGitInfo from 'hosted-git-info' +import { repositoryFiles, type RepositoryFileOptions, type RepositoryRef } from './detail-files.ts' +import type { PluginDetail, PluginDetailDocumentId, PluginDetailOrigin } from './types.ts' + +/** A repository directory whose README is the last detail source. */ +export interface RepositoryLocation extends RepositoryRef { + /** Package directory inside the repository, `''` for its root. */ + readonly directory: string +} + +/** Where one package's detail sources are. */ +export interface DetailLocation { + /** The package's files, when they can be read; `locale/.json` and the README are read from them. */ + readonly files?: DetailFiles + /** Package directory inside `files`, `''` for its root. */ + readonly dir: string + /** + * The repository read last, at its default branch; `manifest` reads `repository` from the package's `package.json` + * in `files`. + */ + readonly repository: RepositoryLocation | 'manifest' | undefined +} + +/** Limits of detail documents and their images. */ +export interface DetailLimits { + readonly maxDocumentBytes: number + readonly maxImageBytes: number + /** Lifetime of a read result, including a failure that a retry would repeat. */ + readonly ttlMs: number +} + +/** Failures that reading again within `ttlMs` would repeat. */ +const LASTING_FAILURES = new Set(['too-large', 'denied', 'invalid']) + +/** + * The repository a URL or shorthand names: hosts npm knows by their owner and project, so a GitHub page such as + * `/o/r/tree/main/pkg` names `o/r`; other http(s) URLs by their path, without a GitLab `/-/` page suffix. + * @param url - a manifest `repository` URL or shorthand, or a market index's `repository.url`. + * @returns the repository, or undefined for a gist, another protocol, or fewer than two path segments. + */ +export function repositoryOf(url: string): RepositoryRef | undefined { + // A committish such as `#semver:^1` names a revision, not the repository. + const declared = url.replace(/#.*$/u, '') + const hosted = hostedGitInfo.fromUrl(declared) + if (hosted !== undefined) { + if (hosted.type === 'gist') return undefined + const project = `${hosted.user}/${hosted.project}` + return { host: hosted.domain.toLowerCase(), project, url: `https://${hosted.domain}/${project}.git` } + } + const address = declared.replace(/^git\+/u, '') + if (!URL.canParse(address)) return undefined + const parsed = new URL(address) + const project = parsed.pathname.replace(/\/-\/.*$/u, '').replace(/^\/+|\/+$/gu, '').replace(/\.git$/u, '') + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username !== '' || parsed.password !== '' || project.split('/').length < 2) { + return undefined + } + return { host: parsed.hostname.toLowerCase(), project, url: `${parsed.protocol}//${parsed.host}/${project}.git` } +} + +/** + * Parse a manifest `repository` into the repository and directory its README is read from. + * @param value - a manifest `repository` string or object. + * @returns the location, or undefined when it names no repository {@link repositoryOf} accepts; a `directory` that + * leaves the repository is dropped. + */ +export function manifestRepository(value: unknown): RepositoryLocation | undefined { + const object = typeof value === 'object' && value !== null ? value as { url?: unknown; directory?: unknown } : undefined + const declared = object === undefined ? value : object.url + if (typeof declared !== 'string') return undefined + const repository = repositoryOf(declared) + if (repository === undefined) return undefined + return { ...repository, directory: typeof object?.directory === 'string' ? normalizedDirectory(object.directory) ?? '' : '' } +} + +/** A `/`-separated directory without empty, `.`, or `..` segments; undefined for one that leaves its root. */ +function normalizedDirectory(directory: string): string | undefined { + const normalized = posix.normalize(directory.replace(/\\/gu, '/')).replace(/\/+$/u, '') + if (normalized === '.' || normalized === '') return '' + return normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized) ? undefined : normalized +} + +/** A read document and the files its images resolve against. */ +interface DetailDocument { + readonly files: DetailFiles + /** Path of the document inside `files`. */ + readonly path: string + /** When a read returned the document or an image beside it was served last. */ + usedAt: number +} + +/** A cached read: the document, or a failure that reading again would repeat. */ +type CachedRead = + | { readonly value: PluginDetail; readonly expiresAt: number } + | { readonly failure: DetailFailure; readonly expiresAt: number } + +/** Reads and caches detail documents for one service. */ +export class DetailReader { + private readonly results = new Map() + private readonly pending = new Map>() + private readonly documents = new Map() + + /** + * @param limits - document and image sizes and the result lifetime. + * @param repositories - single-file API, clone, and request settings of repository reads. + */ + constructor(private readonly limits: DetailLimits, private readonly repositories: RepositoryFileOptions) {} + + /** + * Read a package's detail document, sharing concurrent reads and reusing a result, or a failure that reading again + * would repeat, for `ttlMs`. + * @param location - the package's sources. + * @param languages - the chain from `languageChain` of `dsh-plugin-detail`. + * @param refresh - read again instead of reusing a cached result. + * @returns the first document the sources provide, or `none`. + * @throws {DetailFailure} when a source that should provide a document cannot be read. + */ + read(location: DetailLocation, languages: readonly string[], refresh = false): Promise { + const { repository } = location + const repositoryKey = typeof repository === 'string' ? repository : repository === undefined ? '' : `${repository.url}:${repository.directory}` + const key = JSON.stringify([location.files?.key ?? '', location.dir, repositoryKey, languages]) + const now = Date.now() + const cached = this.results.get(key) + if (!refresh && cached !== undefined && cached.expiresAt > now) { + if ('failure' in cached) return Promise.reject(cached.failure) + if (cached.value.status === 'found') this.use(cached.value.document) + return Promise.resolve(cached.value) + } + let pending = this.pending.get(key) + if (pending === undefined) { + pending = this.resolve(location, languages, this.repositories.lifetime).then((value) => { + this.remember(key, { value, expiresAt: Date.now() + this.limits.ttlMs }) + return value + }, (error: unknown) => { + if (error instanceof DetailFailure && LASTING_FAILURES.has(error.reason)) { + this.remember(key, { failure: error, expiresAt: Date.now() + this.limits.ttlMs }) + } + throw error + }).finally(() => { this.pending.delete(key) }) + this.pending.set(key, pending) + } + return pending + } + + /** + * Read an image beside a document. + * @param id - the document id a detail read returned. + * @param authored - the image destination as the document writes it: relative to the document, or to the package or + * repository root when it starts with `/`; a query or fragment is ignored. + * @returns the bytes and media type, or undefined for an unknown document, a path outside its root, an unsupported + * image type, or a missing file. + * @throws {DetailFailure} when the source fails or the image exceeds `maxImageBytes`. + */ + async image(id: string, authored: string): Promise<{ bytes: Uint8Array; mediaType: string } | undefined> { + const document = this.use(brandString(id)) + const image = document === undefined ? undefined : detailImage(document.path, authored) + if (document === undefined || image === undefined) return undefined + // A request the browser abandons still completes, so a cancelled image never interrupts a shared clone. + const bytes = await document.files.read(image.path, this.limits.maxImageBytes, this.repositories.lifetime) + return bytes === undefined ? undefined : { bytes, mediaType: image.mediaType } + } + + /** + * Mark a document used and forget documents unused for `ttlMs`, so ids live as long as a read or an image request + * returns them. A cached read is younger than `ttlMs` and marks its document, so no cached read names a forgotten id. + */ + private use(id: PluginDetailDocumentId): DetailDocument | undefined { + const now = Date.now() + for (const [known, document] of this.documents) if (now - document.usedAt > this.limits.ttlMs) this.documents.delete(known) + const document = this.documents.get(id) + if (document !== undefined) document.usedAt = now + return document + } + + /** Cache a read and drop expired ones, so the cache holds only reads younger than `ttlMs`. */ + private remember(key: string, read: CachedRead): void { + const now = Date.now() + for (const [cachedKey, cached] of this.results) if (cached.expiresAt <= now) this.results.delete(cachedKey) + this.results.set(key, read) + } + + private async resolve(location: DetailLocation, languages: readonly string[], signal: AbortSignal): Promise { + const { files, dir } = location + let repository = location.repository === 'manifest' ? undefined : location.repository + if (files !== undefined) { + const document = await readPackageDocument(files, dir, languages, this.limits.maxDocumentBytes, signal) + if (document !== undefined) return this.found(document.origin, files, document.path, document.markdown) + if (location.repository === 'manifest') repository = await this.manifestRepositoryOf(files, dir, signal) + } + if (repository === undefined) return { status: 'none' } + const source = repositoryFiles(repository, undefined, this.repositories) + const readme = await source.readme(repository.directory, this.limits.maxDocumentBytes, signal) + return readme === undefined ? { status: 'none' } : this.found('repository', source, readme.path, markdownOf(readme.bytes)) + } + + private async manifestRepositoryOf(files: DetailFiles, dir: string, signal: AbortSignal): Promise { + const bytes = await files.read(joinPath(dir, 'package.json'), this.limits.maxDocumentBytes, signal) + if (bytes === undefined) return undefined + try { + return manifestRepository((JSON.parse(new TextDecoder().decode(bytes)) as { repository?: unknown } | null)?.repository) + } catch (_malformed) { + // A manifest the package manager accepted parses; an unreadable one names no repository to read. + return undefined + } + } + + private found(origin: PluginDetailOrigin, files: DetailFiles, path: string, markdown: string): PluginDetail { + const document = brandString(createHash('sha256').update(`${files.key}\0${path}`).digest('hex').slice(0, 32)) + this.documents.set(document, { files, path, usedAt: Date.now() }) + this.use(document) + return { status: 'found', origin, markdown, document } + } +} diff --git a/packages/boot/plugin-market/src/index.ts b/packages/boot/plugin-market/src/index.ts index dbbce34c4c..f4f2a26c95 100644 --- a/packages/boot/plugin-market/src/index.ts +++ b/packages/boot/plugin-market/src/index.ts @@ -1,5 +1,6 @@ /** Host reader of configured plugin market indexes for the Discover page and the `plugin_market` tool. */ +import { isAbsolute, join, resolve } from 'node:path' import type { Context, Volatile } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import semver from 'semver' @@ -8,15 +9,25 @@ import { marketInstallTarget, type MarketIndex, type MarketPlugin, type MarketVe import type { BundleInfo } from '@deepseek-ai/dsh-plugin-manager/types' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { + DetailFailure, detailImageResponse, languageChain, NPM_FILE_SOURCES, npmFileSourceOrder, npmFiles, +} from '@deepseek-ai/dsh-plugin-detail' +import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types' +import { DetailReader, repositoryOf, type DetailLocation, type RepositoryLocation } from './detail.ts' +import { repositoryFiles, type RepositoryFileOptions } from './detail-files.ts' import { normalizeIndexUrl } from './index-url.ts' +import { RepositoryCache } from './repository-cache.ts' import { parseIndexInWorker } from './parse-thread.ts' import { MarketSearch } from './search.ts' -// Type-only: the `pluginManager` service, `settings.update`, and the Loader's `loader/volatile-update` event. +// Type-only: the `pluginManager` service, `settings.update`, the Loader's `loader/volatile-update` event, and the +// `connection` Fetch routes. import type {} from '@deepseek-ai/dsh-plugin-manager' import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/cordis-plugin-loader' +import type {} from '@deepseek-ai/dsh-client-connection' import type { MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketListingEntry, MarketListingVersion, MarketQuery, MarketSearchResult, + PluginDetail, } from './types.ts' export type * from './types.ts' @@ -36,8 +47,24 @@ export interface Config { fetchTimeoutMs: number /** Largest accepted index body in bytes. */ maxBytes: number - /** Lifetime of a successfully read index document. */ + /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */ cacheTtlMs: number + /** Largest detail document in bytes. */ + detailMaxBytes: number + /** Largest image beside a detail document in bytes. */ + detailImageMaxBytes: number + /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */ + detailRepositoryMaxBytes: number + /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */ + detailCacheMaxBytes: number + /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */ + detailCacheDir?: string + /** Deadline of one Git command of the clone fallback. */ + detailGitTimeoutMs: number + /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources: NpmFileSource[] + /** Hosts serving the GitLab repository files API. */ + detailGitlabHosts: string[] } /** The exact diagnostic of a failure, including a non-Error rejection. */ @@ -55,6 +82,12 @@ function causeChainOf(error: unknown, depth = 0): string { : messageOf(error) } +/** The Fetch route serving images beside detail documents. */ +const DETAIL_FILE_PATH = '/api/plugin-market/detail-file' + +/** Registries whose packages the public npm file CDNs serve. */ +const PUBLIC_REGISTRIES = new Set(['registry.npmjs.org', 'registry.npmmirror.com']) + /** Largest page `search` returns. */ const MAX_LIMIT = 100 @@ -80,6 +113,14 @@ export default class PluginMarket extends TypertRemoteService { fetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000), maxBytes: z.natural().min(1).default(128 * 1024 * 1024), cacheTtlMs: z.natural().default(600000), + detailMaxBytes: z.natural().min(1).default(1024 * 1024), + detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024), + detailRepositoryMaxBytes: z.natural().min(1).default(50 * 1024 * 1024), + detailCacheMaxBytes: z.natural().min(1).default(500 * 1024 * 1024), + detailCacheDir: z.string(), + detailGitTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(60000), + detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]), + detailGitlabHosts: z.array(z.string()).default(['gitlab.com']), }) private readonly lifetime = new AbortController() @@ -87,10 +128,34 @@ export default class PluginMarket extends TypertRemoteService { private readonly pending = new Map>() private readonly errors = new Map() private readonly entryId: string | undefined + private readonly repositories: RepositoryCache + private readonly details: DetailReader constructor(ctx: Context, private readonly config: Config) { super(ctx, 'pluginMarket') this.entryId = ctx.fiber.entry?.options.id + const profile = ctx.get('profileContext') + const cacheDir = profile === undefined + ? config.detailCacheDir + : resolve(profile.dir, config.detailCacheDir ?? join('.plugin-market', 'repositories')) + if (cacheDir === undefined || !isAbsolute(cacheDir)) throw new Error('plugin-market: detailCacheDir must be an absolute path when the service runs without a dsh profile') + const lifetime = this.lifetime.signal + this.repositories = new RepositoryCache({ + dir: cacheDir, maxRepositoryBytes: config.detailRepositoryMaxBytes, maxCacheBytes: config.detailCacheMaxBytes, + // The URL forms repositories are read from, and ssh, which `url..insteadOf` may route them to. + timeoutMs: config.detailGitTimeoutMs, ttlMs: config.cacheTtlMs, transports: ['https', 'http', 'ssh'], lifetime, + // Bounds how far a fetch overshoots `detailRepositoryMaxBytes`; directory walks cost little at this rate. + sizePollMs: 200, + }) + this.details = new DetailReader( + { maxDocumentBytes: config.detailMaxBytes, maxImageBytes: config.detailImageMaxBytes, ttlMs: config.cacheTtlMs }, + this.detailRepositoryOptions(), + ) + ctx.inject(['connection'], (scope) => { + scope.effect(() => scope.connection.fetch.register({ + path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request), + }), `plugin-market: ${DETAIL_FILE_PATH}`) + }) ctx.on('loader/volatile-update', () => { const configured = new Set(this.sources().flatMap(source => source.error === undefined ? [source.url] : [])) for (const url of [...this.cache.keys(), ...this.errors.keys()]) { @@ -103,7 +168,7 @@ export default class PluginMarket extends TypertRemoteService { }) ctx.effect(() => async () => { this.lifetime.abort(new Error('Plugin market disposed')) - await Promise.allSettled(this.pending.values()) + await Promise.allSettled([...this.pending.values(), this.repositories.settled()]) }) } @@ -200,6 +265,101 @@ export default class PluginMarket extends TypertRemoteService { return plugin === undefined ? undefined : (await this.annotator())(plugin) } + /** + * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file + * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names + * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm + * version without one, of its `package.json` `repository`, at the default branch. An npm version from another + * registry has no readable package files, so only the entry's `repository` is read. + * @param url - a configured index URL. + * @param name - the plugin's package name. + * @param version - one listed version. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names + * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not + * configured are not asked. + * @returns the document with its origin and image document id, or `none`. + * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, + * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`. + */ + @Remote + async readEntryDetail( + url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, + ): Promise { + const languages = this.languages(language) + const plugin = (await this.document(url, false)).index.plugins.find(candidate => candidate.name === name) + const listed = plugin?.versions.find(candidate => candidate.version === version) + if (plugin === undefined || listed === undefined) { + throw new RemoteError('plugin-market/unknown-plugin', `${url} lists no ${name}@${version}`, { name, version }) + } + const declared = plugin.repository + const found = declared === undefined ? undefined : repositoryOf(declared.url) + let repository: RepositoryLocation | undefined = found === undefined ? undefined : { ...found, directory: declared?.directory ?? '' } + const { source } = listed + let location: DetailLocation + if (source.type === 'git') { + const ref = repositoryOf(source.url) + /* v8 ignore next -- parseMarketIndex admits only https Git URLs with an owner and a project */ + if (ref === undefined) return { status: 'none' } + const dir = source.path ?? '' + // A repository that names the source directory itself adds nothing to the source. + if (repository?.url === ref.url && repository.directory === dir) repository = undefined + location = { files: repositoryFiles(ref, source.commit, this.detailRepositoryOptions()), dir, repository } + } else if (source.registry === undefined || PUBLIC_REGISTRIES.has(new URL(source.registry).host)) { + location = this.npmLocation(name, version, repository ?? 'manifest', options?.npmFileSources ?? []) + } else { + location = { dir: '', repository } + } + return await this.readDetail(location, languages, options?.refresh === true) + } + + private languages(language: string): string[] { + try { + return languageChain(language) + } catch (error) { + throw new RemoteError('plugin-market/invalid-query', messageOf(error), { field: 'language', reason: 'format' }, { cause: error }) + } + } + + private detailRepositoryOptions(): RepositoryFileOptions { + const { config } = this + return { + timeoutMs: config.fetchTimeoutMs, lifetime: this.lifetime.signal, + gitlabHosts: config.detailGitlabHosts.map(host => host.toLowerCase()), cache: this.repositories, + } + } + + /** The detail location of a published npm version on the configured CDNs, the preferred ones first. */ + private npmLocation(name: string, version: string, repository: RepositoryLocation | 'manifest', preferred: readonly string[]): DetailLocation { + const sources = npmFileSourceOrder(this.config.detailNpmFileSources, preferred) + return { dir: '', repository, files: npmFiles(name, version, sources, { timeoutMs: this.config.fetchTimeoutMs, lifetime: this.lifetime.signal }) } + } + + private async readDetail(location: DetailLocation, languages: readonly string[], refresh: boolean): Promise { + try { + return await this.details.read(location, languages, refresh) + } catch (error) { + if (!(error instanceof DetailFailure)) throw error + throw new RemoteError('plugin-market/detail-unavailable', error.message, { reason: error.reason }, { cause: error }) + } + } + + /** Answer `GET ${DETAIL_FILE_PATH}?document=&path=` with one image beside a read detail document. */ + private async serveDetailFile(request: Request): Promise { + const params = new URL(request.url).searchParams + const document = params.get('document') + const path = params.get('path') + // Images stay cached as long as their document's result. + const cache = `private, max-age=${String(Math.floor(this.config.cacheTtlMs / 1000))}` + if (document === null || path === null) return detailImageResponse('invalid', cache) + try { + return detailImageResponse(await this.details.image(document, path), cache) + } catch (error) { + if (!(error instanceof DetailFailure)) throw error + return detailImageResponse(error.reason, cache) + } + } + private async document(url: string, refresh: boolean, fetchedAt?: number): Promise { const source = this.sources().find(candidate => candidate.url === url) if (source === undefined) throw new RemoteError('plugin-market/unknown-index', `Not a configured index source: ${url}`, { url }) diff --git a/packages/boot/plugin-market/src/repository-cache.ts b/packages/boot/plugin-market/src/repository-cache.ts new file mode 100644 index 0000000000..87a9b51b15 --- /dev/null +++ b/packages/boot/plugin-market/src/repository-cache.ts @@ -0,0 +1,402 @@ +/** + * Cached partial clones for plugin detail documents of repositories that need the user's Git credentials or have no + * anonymous single-file API. Each repository is a bare repository fetched with depth 1 and without file contents; Git + * downloads a file's contents only when it is read. No working tree is created, so no checkout filter, hook, LFS + * download, or symbolic link takes effect. A repository whose operation fails or is cancelled is deleted, so an + * interrupted command never leaves a damaged clone behind. + */ +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, rename, rm, stat, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { execa } from 'execa' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { DetailFailure, readmeName, type FoundFile } from '@deepseek-ai/dsh-plugin-detail' + +/** + * Git deadlines one operation's lock waits for: an operation runs at most a fetch and a file read that contact the + * remote, each bounded by the deadline, plus short local commands. + */ +const OPERATION_COMMANDS = 4 + +/** Limits, location, and transports of the clone cache. */ +export interface RepositoryCacheOptions { + /** Directory holding one bare repository per repository URL. */ + readonly dir: string + /** Largest amount one command adds to a repository; a command that adds more is stopped and the repository deleted. */ + readonly maxRepositoryBytes: number + /** Largest total size; least recently used repositories are deleted after a fetch until the cache fits. */ + readonly maxCacheBytes: number + /** Deadline of one Git command and of one anonymous accessibility check. */ + readonly timeoutMs: number + /** How long a repository's default-branch commit and its anonymous accessibility are reused. */ + readonly ttlMs: number + /** Git transports Git may use, such as `https` and `ssh`; `url..insteadOf` in the user's configuration still applies. */ + readonly transports: readonly string[] + /** Disposal of the owning service. */ + readonly lifetime: AbortSignal + /** Interval between size checks of a repository while Git fetches into it. */ + readonly sizePollMs: number +} + +/** Output that names a connection failure or a timeout rather than a refusal by the server. */ +const NETWORK_FAILURE = new RegExp([ + 'Could not resolve host', 'Failed to connect', 'Connection (?:refused|reset)', 'timed out', 'Network is unreachable', 'SSL', 'TLS', +].join('|'), 'iu') + +/** One Git command's outcome. */ +interface GitResult { + readonly stdout: Buffer + readonly failed: boolean + /** The output exceeded the command's buffer. */ + readonly overflowed: boolean + /** How the command ended, such as its exit code or the deadline it passed, followed by its error output. */ + readonly diagnostic: string +} + +/** A file over the size its read accepts; the clone itself stays usable. */ +class FileTooLarge extends DetailFailure { + /** @param path - the file. @param maxBytes - the accepted size. */ + constructor(path: string, maxBytes: number) { + super('too-large', `${path} exceeds ${String(maxBytes)} bytes`) + } +} + +/** The failure of a Git command that contacts the remote: `network` for a timeout or a connection failure, otherwise `denied`. */ +function remoteFailureOf(result: GitResult, label: string): DetailFailure { + return new DetailFailure(NETWORK_FAILURE.test(result.diagnostic) ? 'network' : 'denied', `${label}: ${result.diagnostic}`) +} + +/** A value reused until it expires. */ +interface Expiring { + readonly value: T + readonly expiresAt: number +} + +async function sizeOf(path: string): Promise { + let total = 0 + for (const entry of await readdir(path, { withFileTypes: true, recursive: true })) { + if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size + } + return total +} + +/** Staging directories that an initialization in progress owns; an older one was left by a stopped process. */ +const STAGING_PREFIX = '.init-' + +/** + * The environment of every Git command: the scrubbed parent environment without Git overrides such as `GIT_DIR`, which a + * hook that launched DSH can leave, and without prompts or LFS downloads. + */ +function gitEnv(): Record { + const env = Object.fromEntries(Object.entries(scrubbedParentEnv()) + .filter(([name]) => !name.startsWith('GIT_') || name === 'GIT_SSH' || name === 'GIT_SSH_COMMAND')) + // A user's own ssh program keeps precedence; the default only keeps ssh from waiting for a prompt. + const ssh = env.GIT_SSH === undefined && env.GIT_SSH_COMMAND === undefined ? { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' } : {} + return { + ...env, ...ssh, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', + GCM_INTERACTIVE: 'never', GIT_LFS_SKIP_SMUDGE: '1', GIT_LITERAL_PATHSPECS: '1', + } +} + +/** Partial clones of detail repositories under one directory, owned by one service. */ +export class RepositoryCache { + private readonly locks = new Map>() + private readonly heads = new Map>() + /** + * Whether `git --version` has succeeded. A missing Git is checked this way before any operation, because Windows + * reports a missing command as an ordinary exit code that would otherwise read as a refused fetch. + */ + private gitStarts = false + private readonly visibility = new Map>() + private readonly checks = new Map>() + private readonly running = new Set>() + + /** @param options - location, limits, deadline, transports, and lifetime. */ + constructor(private readonly options: RepositoryCacheOptions) {} + + /** + * Whether a repository's smart HTTP endpoint answers without credentials, which makes a missing single-file API answer + * authoritative. The request carries no user configuration, credential helper, or `.netrc` entry; concurrent checks of + * one repository share it, and its answer is reused for `ttlMs`. + * @param url - the repository's https URL, ending in `.git`. + * @returns true for a 2xx answer; false for 401, 403, 404, or a redirect, which hosts send for a private repository. + * @throws {DetailFailure} `network` when the host cannot be reached or answers another status. + */ + isPublic(url: string): Promise { + const cached = this.visibility.get(url) + if (cached !== undefined && cached.expiresAt > Date.now()) return Promise.resolve(cached.value) + let pending = this.checks.get(url) + if (pending === undefined) { + pending = this.check(url).finally(() => { this.checks.delete(url) }) + this.checks.set(url, pending) + } + return pending + } + + private async check(url: string): Promise { + const endpoint = `${url}/info/refs?service=git-upload-pack` + let response: Response + try { + response = await fetch(endpoint, { redirect: 'manual', signal: AbortSignal.any([this.options.lifetime, AbortSignal.timeout(this.options.timeoutMs)]) }) + } catch (error) { + throw new DetailFailure('network', `Could not reach ${endpoint}: ${(error as Error).message}`, { cause: error }) + } + await response.body?.cancel() + const { status } = response + if (!response.ok && status !== 401 && status !== 403 && status !== 404 && (status < 300 || status > 399)) { + throw new DetailFailure('network', `${endpoint} answered HTTP ${String(status)}`) + } + this.visibility.set(url, { value: response.ok, expiresAt: Date.now() + this.options.ttlMs }) + return response.ok + } + + /** + * Read one regular file of a repository. + * @param url - the repository URL. + * @param commit - a full commit id, or undefined for the default branch. + * @param path - `/`-separated path relative to the repository root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the bytes, or undefined when the commit has no regular file at `path`. + * @throws {DetailFailure} when Git cannot fetch the commit, the repository grows past its limit, or the file exceeds `maxBytes`. + */ + read(url: string, commit: string | undefined, path: string, maxBytes: number, signal: AbortSignal): Promise { + return this.locked(url, async (repository) => { + const resolved = await this.commit(repository, url, commit, signal) + const blob = (await this.files(repository, resolved, path, signal)).get(path) + return blob === undefined ? undefined : await this.blob(repository, url, blob, path, maxBytes, signal) + }) + } + + /** + * Read the README of a repository directory: `README.md`, `README.markdown`, or `README` in any letter case. + * @param url - the repository URL. + * @param commit - a full commit id, or undefined for the default branch. + * @param dir - directory relative to the repository root; `''` for the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the file, or undefined when the directory has none. + * @throws {DetailFailure} as {@link read} does. + */ + readme(url: string, commit: string | undefined, dir: string, maxBytes: number, signal: AbortSignal): Promise { + return this.locked(url, async (repository) => { + const resolved = await this.commit(repository, url, commit, signal) + const files = await this.files(repository, resolved, dir === '' ? undefined : `${dir}/`, signal) + const prefix = dir === '' ? '' : `${dir}/` + const name = readmeName([...files.keys()].map(path => path.slice(prefix.length))) + if (name === undefined) return undefined + const path = `${prefix}${name}` + return { path, bytes: await this.blob(repository, url, files.get(path) as string, path, maxBytes, signal) } + }) + } + + /** Wait until every running Git command has ended; the lifetime signal stops them. */ + async settled(): Promise { + await Promise.allSettled([...this.running]) + } + + /** + * Serialize operations on one repository, so a fetch and the reads after it never interleave, and delete the + * repository when an operation fails or is cancelled for any reason but a file over its read's size. + */ + private locked(url: string, operation: (repository: string) => Promise): Promise { + const repository = join(this.options.dir, createHash('sha256').update(url).digest('hex').slice(0, 24)) + return this.serialized(repository, () => operation(repository).catch(async (error: unknown) => { + if (!(error instanceof FileTooLarge)) await rm(repository, { recursive: true, force: true }) + throw error + })) + } + + /** + * Run an operation after every earlier operation on the same repository directory has ended, in this service and in + * other processes that share the cache directory, such as Web and Desktop on one profile. + */ + private serialized(repository: string, operation: () => Promise): Promise { + const previous = this.locks.get(repository) ?? Promise.resolve() + const next = previous.then(async () => { + await mkdir(this.options.dir, { recursive: true }) + // An operation runs at most this many Git commands, each bounded by the Git deadline. + const waitMs = OPERATION_COMMANDS * this.options.timeoutMs + return await withFileLock(repository, operation, { waitMs, signal: this.options.lifetime }) + }) + // The caller receives the outcome; the lock and the settlement wait only for completion. + const tracked: Promise = next.then(() => undefined, () => undefined).finally(() => { + if (this.locks.get(repository) === tracked) this.locks.delete(repository) + this.running.delete(tracked) + }) + this.locks.set(repository, tracked) + this.running.add(tracked) + return next + } + + /** The commit to read: a fetched commit, fetching it or the default branch when necessary. */ + private async commit(repository: string, url: string, commit: string | undefined, signal: AbortSignal): Promise { + if (!this.gitStarts) { + // `git version ` is one short line. + await this.run(this.options.dir, ['--version'], signal, 1024).catch((error: unknown) => { + throw new DetailFailure('git-unavailable', (error as Error).message, { cause: error }) + }) + this.gitStarts = true + } + if (!existsSync(repository)) { + // A repository appears complete or not at all: it is initialized beside its final name, then renamed. + const staging = await mkdtemp(join(this.options.dir, STAGING_PREFIX)) + try { + await this.run(staging, ['init', '--quiet', '--bare', '--template='], signal) + for (const [key, value] of [['remote.origin.url', url], ['remote.origin.promisor', 'true'], ['remote.origin.partialclonefilter', 'blob:none']]) { + await this.run(staging, ['config', key as string, value as string], signal) + } + await rename(staging, repository) + } finally { + await rm(staging, { recursive: true, force: true }) + } + } + await utimes(repository, new Date(), new Date()) + // An object lookup would fetch a missing commit lazily from the promisor remote, without the size watch; a ref + // written after a completed fetch records the commit instead, and eviction removes it with the repository. + const head = this.heads.get(url) + const known = commit ?? (head !== undefined && head.expiresAt > Date.now() ? head.value : undefined) + if (known !== undefined && !(await this.git(['show-ref', '--verify', '--quiet', `refs/dsh/${known}`], repository, signal)).failed) return known + await this.fetch(repository, url, commit ?? 'HEAD', signal) + const fetched = commit ?? (await this.run(repository, ['rev-parse', 'FETCH_HEAD'], signal)).toString('utf8').trim() + await this.run(repository, ['update-ref', `refs/dsh/${fetched}`, fetched], signal) + if (commit === undefined) this.heads.set(url, { value: fetched, expiresAt: Date.now() + this.options.ttlMs }) + return fetched + } + + /** Fetch one commit without file contents. */ + private async fetch(repository: string, url: string, target: string, signal: AbortSignal): Promise { + const args = ['fetch', '--quiet', '--depth', '1', '--filter=blob:none', '--no-tags', 'origin', target] + await this.watched(repository, url, args, signal, `${url}: git fetch`) + await this.evict(repository) + } + + /** + * Run a Git command that may download objects, stopping it when the repository grows past its limit during or by the + * command; the caller's lock then deletes the repository. + */ + private async watched( + repository: string, url: string, args: readonly string[], signal: AbortSignal, label: string, + maxBuffer = this.options.maxRepositoryBytes, + ): Promise { + const limit = this.options.maxRepositoryBytes + // The limit applies to what one command adds, so a clone that earlier reads filled stays usable. + const before = await sizeOf(repository) + const oversized = new AbortController() + const timer = setInterval(() => { + void sizeOf(repository).then((size) => { if (size - before > limit) oversized.abort() }, /* v8 ignore next -- Git renamed or + deleted a file during the walk, which no test can time; the next tick measures again */ (_racing: unknown) => {}) + }, this.options.sizePollMs) + let result: GitResult + try { + result = await this.git(args, repository, signal, oversized.signal, maxBuffer) + } finally { + clearInterval(timer) + } + if (oversized.signal.aborted || (!result.failed && await sizeOf(repository) - before > limit)) { + throw new DetailFailure('too-large', `${url}: one fetch adds more than ${String(limit)} bytes`) + } + if (result.failed && !result.overflowed) throw remoteFailureOf(result, label) + return result + } + + /** + * Delete least recently used repositories until the cache fits, `kept` last, and staging directories older than the + * Git deadline. Each deletion is queued behind the operations on its repository and not awaited, because the caller + * holds the lock of `kept` and another repository's queued operation may be waiting to evict `kept`. + */ + private async evict(kept: string): Promise { + const now = Date.now() + const entries: { path: string; size: number; used: number }[] = [] + for (const entry of await readdir(this.options.dir, { withFileTypes: true })) { + const path = join(this.options.dir, entry.name) + try { + const used = (await stat(path)).mtimeMs + if (entry.name.startsWith(STAGING_PREFIX)) { + if (now - used > this.options.timeoutMs) await rm(path, { recursive: true, force: true }) + } else if (entry.isDirectory()) { + entries.push({ path, size: await sizeOf(path), used }) + } + } catch (_removed) { + // Another operation deleted the directory while it was measured; it no longer counts. + } + } + let total = entries.reduce((sum, entry) => sum + entry.size, 0) + // The repository in use goes last: it is deleted only when it alone exceeds the cache, after its operation ends. + for (const entry of entries.sort((a, b) => Number(a.path === kept) - Number(b.path === kept) || a.used - b.used)) { + if (total <= this.options.maxCacheBytes) return + void this.serialized(entry.path, () => rm(entry.path, { recursive: true, force: true })).catch(/* v8 ignore next -- a + forced recursive delete fails only on file permissions no test sets; the repository stays and is measured again + after the next fetch */ (_unremovable: unknown) => {}) + total -= entry.size + } + } + + /** + * The regular files directly below a tree path, or the file at a path, with their blob ids; listing reads only trees, + * so it fetches no file contents. + */ + private async files(repository: string, commit: string, path: string | undefined, signal: AbortSignal): Promise> { + const listing = (await this.run(repository, ['ls-tree', '-z', commit, ...path === undefined ? [] : ['--', path]], signal)).toString('utf8') + const files = new Map() + for (const line of listing.split('\0')) { + // Symbolic links (120000), submodules (160000), and trees are not files. + const fields = /^100(?:644|755) blob ([0-9a-f]+)\t(.+)$/su.exec(line) + if (fields !== null) files.set(fields[2] as string, fields[1] as string) + } + return files + } + + /** Read a blob, fetching its contents under the repository size limit when the clone does not hold them yet. */ + private async blob( + repository: string, url: string, blob: string, path: string, maxBytes: number, signal: AbortSignal, + ): Promise { + // The buffer stops Git once the contents pass `maxBytes`, so an oversized file never enters memory whole. + const result = await this.watched(repository, url, ['cat-file', 'blob', blob], signal, `git cat-file ${path}`, maxBytes) + if (result.overflowed) throw new FileTooLarge(path, maxBytes) + // Reading a blob can download it, so the cache total is enforced after every read as after every fetch. + await this.evict(repository) + return result.stdout + } + + /** + * Run a Git command on local objects and return its output. A failure, such as a missing Git, a full disk, or a timeout, + * is a plain error: it says nothing about the repository, so no read caches it. + */ + private async run(repository: string, args: readonly string[], signal: AbortSignal, maxBuffer?: number): Promise { + const result = await this.git(args, repository, signal, undefined, maxBuffer) + if (result.failed) throw new Error(`git ${args[0] as string}: ${result.diagnostic}`) + return result.stdout + } + + /** + * Run Git with options that keep it from running repository-supplied programs and allow only the configured transports. + * Cancellation by `signal` or the lifetime rejects with its reason; `stop` ends Git with a failed result. + */ + private async git( + args: readonly string[], cwd: string, signal: AbortSignal, stop?: AbortSignal, maxBuffer = this.options.maxRepositoryBytes, + ): Promise { + const safety = [ + // An empty `core.askPass` ends Git's askpass lookup where Windows drops the empty `GIT_ASKPASS`, so no dialog opens. + 'core.askPass=', 'core.hooksPath=/dev/null', 'core.fsmonitor=false', 'submodule.recurse=false', 'gc.auto=0', 'maintenance.auto=false', + 'protocol.allow=never', ...this.options.transports.map(transport => `protocol.${transport}.allow=always`), + ].flatMap(setting => ['-c', setting]) + const result = await execa('git', [...safety, ...args], { + // A blob's last byte may be a newline, so the output is kept exactly as Git wrote it. + cwd, env: gitEnv(), extendEnv: false, stdin: 'ignore', encoding: 'buffer', stripFinalNewline: false, reject: false, + maxBuffer: { stdout: maxBuffer, stderr: this.options.maxRepositoryBytes }, + timeout: this.options.timeoutMs, cancelSignal: AbortSignal.any([signal, this.options.lifetime, ...stop === undefined ? [] : [stop]]), + killDescendants: true, killSignal: 'SIGKILL', + }) + signal.throwIfAborted() + this.options.lifetime.throwIfAborted() + // Git that is missing or cannot run says nothing about the repository, so no read caches the failure. + return { + stdout: Buffer.from(result.stdout), failed: result.failed, + overflowed: result.isMaxBuffer && result.stdout.byteLength >= maxBuffer, + diagnostic: `${String(result.shortMessage)}\n${Buffer.from(result.stderr).toString('utf8').trim()}`, + } + } +} diff --git a/packages/boot/plugin-market/src/types.ts b/packages/boot/plugin-market/src/types.ts index 78ff20aedb..4a4663ad77 100644 --- a/packages/boot/plugin-market/src/types.ts +++ b/packages/boot/plugin-market/src/types.ts @@ -1,7 +1,9 @@ /** JSON-safe records and events of the plugin market Remote namespace. */ import type { MarketIndexMetadata, MarketInstallTarget, MarketPlugin, MarketVersion } from '@deepseek-ai/dsh-plugin-market-index' import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest' +import type { Branded } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-typert-protocol' +import type { DetailFailureReason } from '@deepseek-ai/dsh-plugin-detail/types' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -17,6 +19,15 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'plugin-market/invalid-query': { readonly field: string; readonly reason: string } /** Index sources cannot be saved without the settings service and this service's profile entry. */ 'plugin-market/settings-unavailable': Record + /** The index lists no such plugin or version. */ + 'plugin-market/unknown-plugin': { readonly name: string; readonly version: string } + /** + * A detail document exists but cannot be read: `too-large` beyond a size limit, `network` when a source cannot be + * reached, `denied` when Git refuses the repository with the user's credentials, `git-unavailable` when the + * repository must be cloned and Git cannot run, `invalid` for a malformed locale file or `meta.detail`, or a named + * file that does not exist. + */ + 'plugin-market/detail-unavailable': { readonly reason: DetailFailureReason } } } @@ -127,6 +138,27 @@ export interface MarketSearchResult { readonly nextOffset: number | null } +/** Opaque id of a read detail document; image requests name it to read files beside the document. */ +export type PluginDetailDocumentId = Branded<'PluginDetailDocumentId'> + +/** + * Where a detail document comes from: `locale` for the file `meta.detail` names, `readme` for the package's README, and + * `repository` for the README of the directory the package's `repository` names. + */ +export type PluginDetailOrigin = 'locale' | 'readme' | 'repository' + +/** A plugin's long description, or `none` when no source provides one. */ +export type PluginDetail = + | { + readonly status: 'found' + readonly origin: PluginDetailOrigin + /** Markdown text, decoded as UTF-8. */ + readonly markdown: string + /** Relative image paths of the document resolve beside it through `GET /api/plugin-market/detail-file`. */ + readonly document: PluginDetailDocumentId + } + | { readonly status: 'none' } + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/boot/plugin-market/tests/built-worker.e2e.ts b/packages/boot/plugin-market/tests/built-worker.e2e.ts index d5a52fe34d..da851ca37a 100644 --- a/packages/boot/plugin-market/tests/built-worker.e2e.ts +++ b/packages/boot/plugin-market/tests/built-worker.e2e.ts @@ -40,7 +40,7 @@ describe.skipIf(!built)('built index parsing (plain node)', () => { const ctx = new Context() try { await ctx.plugin(Manager) - await ctx.plugin(PluginMarket, { indexes: Object.keys(bodies) }) + await ctx.plugin(PluginMarket, { indexes: Object.keys(bodies), detailCacheDir: '/nonexistent/plugin-market-detail-cache' }) const query = { sort: { kind: 'index' }, offset: 0, limit: 10 } const valid = await ctx.pluginMarket.search('https://m.example/valid.json', query) const invalid = await ctx.pluginMarket.search('https://m.example/invalid.json', query).catch(error => error) diff --git a/packages/boot/plugin-market/tests/configuration.spec.ts b/packages/boot/plugin-market/tests/configuration.spec.ts index 5a6e21bb2a..3594b1993f 100644 --- a/packages/boot/plugin-market/tests/configuration.spec.ts +++ b/packages/boot/plugin-market/tests/configuration.spec.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy import { tmpdir } from 'node:os' import { join } from 'node:path' import { Service, type Context } from '@deepseek-ai/cordis' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import ConfigEditor from '@deepseek-ai/dsh-config-editor' import Settings from '@deepseek-ai/dsh-settings' @@ -55,3 +55,74 @@ it('saves index sources without remounting, announces them, and restores them at try { await ctx?.fiber.dispose() } finally { rmSync(home, { recursive: true, force: true }) } } }) + +/** The `connection` Fetch route registry, recording the routes the market registers. */ +class RecordingConnection extends Service { + static readonly routes = new Map Promise>() + readonly fetch = { + register: (route: { path: string; fetch: (request: Request) => Promise }) => { + RecordingConnection.routes.set(route.path, route.fetch) + return () => Promise.resolve(void RecordingConnection.routes.delete(route.path)) + }, + } + + constructor(ctx: Context) { super(ctx, 'connection') } +} + +it('reads a market entry detail document and serves its images through the composed profile', async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-'))) + const commit = 'c'.repeat(40) + const raw = `https://raw.githubusercontent.com/o/r/${commit}/pkg` + const files: Record = { + 'https://first.example/index.json': JSON.stringify({ format: 1, title: 'Market', plugins: [{ + name: 'from-git', versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/r', commit, path: 'pkg' }, engines: { dsh: '*' } }], + }] }), + [`${raw}/locale/en.json`]: JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + [`${raw}/locale/zh.json`]: JSON.stringify({ meta: { detail: 'guide/DETAIL.zh.md' } }), + [`${raw}/guide/DETAIL.md`]: '# Test entry\n\n![diagram](img/diagram.svg)\n', + [`${raw}/guide/DETAIL.zh.md`]: '# 测试条目\n', + [`${raw}/guide/img/diagram.svg`]: '', + // The repository is public, so a file missing from the raw host is missing from the repository. + 'https://github.com/o/r.git/info/refs?service=git-upload-pack': 'refs', + } + vi.stubGlobal('fetch', vi.fn((url: string) => Promise.resolve(url in files ? new Response(files[url]) : new Response('missing', { status: 404 })))) + let ctx: Awaited> | undefined + try { + const dir = join(home, 'profiles', 'test') + initProfile(dir, ['test-bundle']) + const bundle = join(dir, 'node_modules', 'test-bundle') + mkdirSync(bundle, { recursive: true }) + writeFileSync(join(home, 'package.json'), '{"name":"test-installation"}\n') + writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: 'test-bundle', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } })) + writeFileSync(join(bundle, 'cordis.patch.yml'), readFileSync(new URL('./fixtures/market.patch.yml', import.meta.url))) + writeFileSync(join(dir, 'cordis.yml'), '- name: cordis:connection\n') + const profile: ProfileContext = { + name: 'test', startedBundles: ['test-bundle'], dir, patchPath: join(dir, 'cordis.patch.yml'), + installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + } + ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => { + root.provide('profileContext', profile) + Object.assign(root.loader.builtins, { + editor: ConfigEditor, settings: Settings, manager: StubPluginManager, market: PluginMarket, connection: RecordingConnection, + }) + }) + const market = ctx.get('pluginMarket')! + const detail = await market.readEntryDetail('https://first.example/index.json', 'from-git', '1.0.0', 'en-US') + expect(detail).toMatchObject({ status: 'found', origin: 'locale', markdown: '# Test entry\n\n![diagram](img/diagram.svg)\n' }) + expect(await market.readEntryDetail('https://first.example/index.json', 'from-git', '1.0.0', 'zh-CN')).toMatchObject({ origin: 'locale', markdown: '# 测试条目\n' }) + if (detail.status !== 'found') throw new Error('expected a document') + const serve = RecordingConnection.routes.get('/api/plugin-market/detail-file')! + const image = await serve(new Request(`http://dsh.local/api/plugin-market/detail-file?document=${detail.document}&path=img%2Fdiagram.svg`)) + expect(image.status).toBe(200) + expect(image.headers.get('content-type')).toBe('image/svg+xml') + expect(image.headers.get('content-security-policy')).toContain('sandbox') + expect(await image.text()).toContain(' bytes === undefined ? undefined : new TextDecoder().decode(bytes) + +afterEach(() => { vi.unstubAllGlobals() }) + +/** Answer requests by URL with a fresh response each time; any other URL fails the test. */ +function serve(routes: Record Response)>) { + const fetch = vi.fn((url: string) => { + const route = routes[url] + if (route === undefined) throw new Error(`unexpected fetch ${url}`) + if (route instanceof Error) return Promise.reject(route) + return Promise.resolve(route()) + }) + vi.stubGlobal('fetch', fetch) + return fetch +} + +/** A clone cache double recording its calls. */ +function clones(answers: { public?: boolean | Error; read?: string; readme?: string } = {}) { + const calls: string[] = [] + const cache = { + isPublic: vi.fn((url: string) => { + calls.push(`public ${url}`) + return answers.public instanceof Error ? Promise.reject(answers.public) : Promise.resolve(answers.public ?? false) + }), + read: vi.fn((url: string, commit: string | undefined, path: string) => { + calls.push(`read ${url} ${String(commit)} ${path}`) + return Promise.resolve(answers.read === undefined ? undefined : Buffer.from(answers.read)) + }), + readme: vi.fn((url: string, commit: string | undefined, dir: string) => { + calls.push(`readme ${url} ${String(commit)} ${dir}`) + return Promise.resolve(answers.readme === undefined ? undefined : { path: `${dir}/README.md`, bytes: Buffer.from(answers.readme) }) + }), + } + const settings: RepositoryFileOptions = { + ...options, gitlabHosts: ['gitlab.example'], cache, + } + return { calls, settings } +} + +const GITHUB = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git' } +const GITLAB = { host: 'gitlab.example', project: 'g/s/p', url: 'https://gitlab.example/g/s/p.git' } +const OTHER = { host: 'git.example', project: 'o/r', url: 'https://git.example/o/r.git' } +const COMMIT = 'a'.repeat(40) + +it('reads public repositories through the GitHub and GitLab single-file APIs', async () => { + serve({ + [`https://raw.githubusercontent.com/o/r/${COMMIT}/p/README.md`]: () => new Response('# gh'), + 'https://raw.githubusercontent.com/o/r/HEAD/README.md': () => new Response('no', { status: 404 }), + 'https://raw.githubusercontent.com/o/r/HEAD/readme.md': () => new Response('no', { status: 404 }), + 'https://raw.githubusercontent.com/o/r/HEAD/Readme.md': () => new Response('# root'), + 'https://raw.githubusercontent.com/o/r/HEAD/missing.png': () => new Response('no', { status: 404 }), + 'https://gitlab.example/api/v4/projects/g%2Fs%2Fp/repository/files/guide%2Fa.md/raw?ref=HEAD': () => new Response('# gl'), + 'https://raw.githubusercontent.com/o/r/HEAD/down.md': () => new Response('down', { status: 500 }), + }) + const { calls, settings } = clones({ public: true }) + const pinned = repositoryFiles(GITHUB, COMMIT, settings) + expect(pinned.key).toBe(`git:https://github.com/o/r.git#${COMMIT}`) + expect(await pinned.readme('p', 100, signal)).toMatchObject({ path: 'p/README.md' }) + const head = repositoryFiles(GITHUB, undefined, settings) + expect(await head.readme('', 100, signal)).toMatchObject({ path: 'Readme.md' }) + expect(await head.read('missing.png', 100, signal)).toBeUndefined() + await expect(head.read('down.md', 100, signal)).rejects.toMatchObject({ reason: 'network' }) + expect(text(await repositoryFiles(GITLAB, undefined, settings).read('guide/a.md', 100, signal))).toBe('# gl') + // Each ambiguous answer asks whether the repository is public; the clone cache shares and caches that check. + expect(calls).toEqual(['public https://github.com/o/r.git', 'public https://github.com/o/r.git']) +}) + +it('reads repositories that need credentials, and repositories on hosts without a single-file API, from the clone', async () => { + serve({ + 'https://raw.githubusercontent.com/o/r/HEAD/README.md': () => new Response('no', { status: 404 }), + 'https://raw.githubusercontent.com/o/r/HEAD/a.png': () => new Response('no', { status: 403 }), + }) + const { calls, settings } = clones({ public: false, read: 'png', readme: '# private' }) + const files = repositoryFiles(GITHUB, undefined, settings) + expect(await files.readme('', 100, signal)).toMatchObject({ path: '/README.md' }) + expect(text(await files.read('a.png', 100, signal))).toBe('png') + const other = repositoryFiles(OTHER, COMMIT, settings) + expect(text(await other.read('b.png', 100, signal))).toBe('png') + expect((await other.readme('d', 100, signal))?.path).toBe('d/README.md') + expect(calls).toEqual([ + 'public https://github.com/o/r.git', + 'readme https://github.com/o/r.git undefined ', + 'public https://github.com/o/r.git', + 'read https://github.com/o/r.git undefined a.png', + `read https://git.example/o/r.git ${COMMIT} b.png`, + `readme https://git.example/o/r.git ${COMMIT} d`, + ]) + const cleartext = repositoryFiles({ host: 'git.example', project: 'o/r', url: 'http://git.example/o/r.git' }, undefined, settings) + expect(text(await cleartext.read('c.png', 100, signal))).toBe('png') +}) + +it('reports a failed anonymous check as the read\'s failure', async () => { + serve({ 'https://raw.githubusercontent.com/o/r/HEAD/a.md': () => new Response('no', { status: 404 }) }) + const { calls, settings } = clones({ public: new DetailFailure('network', 'offline') }) + const files = repositoryFiles(GITHUB, undefined, settings) + await expect(files.read('a.md', 100, signal)).rejects.toMatchObject({ reason: 'network' }) + await expect(files.read('a.md', 100, signal)).rejects.toMatchObject({ reason: 'network' }) + expect(calls).toEqual(['public https://github.com/o/r.git', 'public https://github.com/o/r.git']) +}) diff --git a/packages/boot/plugin-market/tests/detail-reader.spec.ts b/packages/boot/plugin-market/tests/detail-reader.spec.ts new file mode 100644 index 0000000000..46edcfa709 --- /dev/null +++ b/packages/boot/plugin-market/tests/detail-reader.spec.ts @@ -0,0 +1,182 @@ +/** Market entry detail documents: package document, repository README, caching, and images beside a read document. */ +import { afterEach, expect, it, vi } from 'vitest' +import { DetailFailure, readmeName, type DetailFiles } from '@deepseek-ai/dsh-plugin-detail' +import { DetailReader, manifestRepository, repositoryOf, type DetailLocation } from '../src/detail.ts' +import type { RepositoryFileOptions } from '../src/detail-files.ts' + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + +/** In-memory files under one key. */ +function memory(key: string, contents: Record): DetailFiles & { reads: string[] } { + const reads: string[] = [] + const read = (path: string, maxBytes: number) => { + reads.push(path) + const value = contents[path] + if (value === undefined) return Promise.resolve(undefined) + if (value.length > maxBytes) return Promise.reject(new DetailFailure('too-large', path)) + return Promise.resolve(new TextEncoder().encode(value)) + } + return { + key, reads, read, + async readme(dir, maxBytes) { + const prefix = dir === '' ? '' : `${dir}/` + const name = readmeName(Object.keys(contents).filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length))) + if (name === undefined) return undefined + const path = `${prefix}${name}` + return { path, bytes: (await read(path, maxBytes)) as Uint8Array } + }, + } +} + +const lifetime = new AbortController().signal + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +function reader(repositoryContents: Record = {}, limits: { ttlMs?: number } = {}) { + const raw = vi.fn((url: string) => Promise.resolve(new Response(repositoryContents[url] ?? 'missing', { status: url in repositoryContents ? 200 : 404 }))) + vi.stubGlobal('fetch', raw) + const repositories: RepositoryFileOptions = { + timeoutMs: 1000, lifetime, gitlabHosts: [], + cache: { isPublic: () => Promise.resolve(true), read: () => Promise.resolve(undefined), readme: () => Promise.resolve(undefined) }, + } + return new DetailReader({ maxDocumentBytes: 100, maxImageBytes: 10, ttlMs: limits.ttlMs ?? 60_000 }, repositories) +} + +const EN_ZH = ['zh', 'en'] + +it('parses repositories from manifests, shorthands, and web pages', () => { + expect(manifestRepository('github:o/r')).toEqual({ host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' }) + expect(manifestRepository({ type: 'git', url: 'git+https://gitlab.com/g/s/p.git#main', directory: './packages/a/' })) + .toEqual({ host: 'gitlab.com', project: 'g/s/p', url: 'https://gitlab.com/g/s/p.git', directory: 'packages/a' }) + expect(manifestRepository({ url: 'git+https://git.example:8443/team/repo.git', directory: '../x' })) + .toEqual({ host: 'git.example', project: 'team/repo', url: 'https://git.example:8443/team/repo.git', directory: '' }) + expect(manifestRepository({ url: 'github:o/r', directory: '/abs' })).toMatchObject({ directory: '' }) + expect(manifestRepository({ url: 'https://git.example/team/repo', directory: '.' })).toMatchObject({ directory: '' }) + expect(manifestRepository({ url: 'https://git.example/team/repo', directory: 7 })).toMatchObject({ directory: '' }) + for (const value of [undefined, null, 7, { url: 7 }, 'not a repository', 'ftp://git.example/a/b', 'https://git.example/one', 'gist:abc']) { + expect(manifestRepository(value)).toBeUndefined() + } + expect(repositoryOf('https://github.com/o/r/tree/main/pkg')).toEqual({ host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git' }) + expect(repositoryOf('https://git.corp.example/g/p/-/tree/main')).toEqual({ host: 'git.corp.example', project: 'g/p', url: 'https://git.corp.example/g/p.git' }) + expect(repositoryOf('https://user:pw@git.example/a/b')).toBeUndefined() + expect(repositoryOf('http://intranet/a/b/')).toEqual({ host: 'intranet', project: 'a/b', url: 'http://intranet/a/b.git' }) +}) + +it('reads the package document first, and reports an invalid one', async () => { + const files = memory('npm:a@1', { + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'guide/DETAIL.md': '# Detail', + 'README.md': '# Readme', + }) + const details = reader() + expect(await details.read({ files, dir: '', repository: 'manifest' }, EN_ZH)).toMatchObject({ status: 'found', origin: 'locale', markdown: '# Detail' }) + expect(await details.read({ files: memory('npm:r@1', { 'README.md': '# Readme' }), dir: '', repository: undefined }, ['en'])) + .toMatchObject({ origin: 'readme', markdown: '# Readme' }) + const missing = memory('npm:b@1', { 'locale/en.json': JSON.stringify({ meta: { detail: 'GONE.md' } }) }) + await expectRejection(details.read({ files: missing, dir: '', repository: undefined }, ['en']), { reason: 'invalid' }, 'GONE.md') +}) + +it('reads the repository README last, from package.json or the given repository, at the default branch', async () => { + const details = reader({ + 'https://raw.githubusercontent.com/o/r/HEAD/packages/a/README.md': '# Repo A', + 'https://raw.githubusercontent.com/o/r/HEAD/README.md': '# Repo', + }) + const files = memory('npm:a@1', { 'package.json': JSON.stringify({ repository: { url: 'github:o/r', directory: 'packages/a' } }) }) + expect(await details.read({ files, dir: '', repository: 'manifest' }, ['en'])).toMatchObject({ origin: 'repository', markdown: '# Repo A' }) + expect(await details.read({ files: memory('npm:b@1', {}), dir: '', repository: 'manifest' }, ['en'])).toEqual({ status: 'none' }) + expect(await details.read({ files: memory('npm:c@1', { 'package.json': '{' }), dir: '', repository: 'manifest' }, ['en'])).toEqual({ status: 'none' }) + const repository = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' } + expect(await details.read({ dir: '', repository }, ['en'])).toMatchObject({ origin: 'repository', markdown: '# Repo' }) + expect(await details.read({ dir: '', repository: { ...repository, directory: 'missing' } }, ['en'])).toEqual({ status: 'none' }) + expect(await details.read({ dir: '', repository: undefined }, ['en'])).toEqual({ status: 'none' }) +}) + +it('reports a failure of the declared repository', async () => { + const details = reader() + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + const github = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' } + await expectRejection(details.read({ dir: '', repository: github }, ['en']), { reason: 'network' }, 'offline') +}) + +it('shares concurrent reads, reuses results and lasting failures until they expire, and asks again after a network failure', async () => { + vi.useFakeTimers() + const files = memory('npm:a@1', { 'README.md': '# A' }) + const details = reader({}, { ttlMs: 1000 }) + const location: DetailLocation = { files, dir: '', repository: undefined } + const [first, second] = await Promise.all([details.read(location, ['en']), details.read(location, ['en'])]) + expect(second).toBe(first) + expect(await details.read(location, ['en'])).toBe(first) + expect(files.reads.filter(path => path === 'README.md')).toHaveLength(1) + vi.advanceTimersByTime(1001) + await details.read(location, ['en']) + expect(files.reads.filter(path => path === 'README.md')).toHaveLength(2) + + const big = memory('npm:big@1', { 'README.md': 'x'.repeat(200) }) + await expect(details.read({ files: big, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'too-large' }) + await expect(details.read({ files: big, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'too-large' }) + expect(big.reads.filter(path => path === 'README.md')).toHaveLength(1) + + let calls = 0 + const flaky: DetailFiles = { + key: 'flaky', read: () => Promise.resolve(undefined), + readme: () => (++calls === 1 ? Promise.reject(new DetailFailure('network', 'offline')) : Promise.resolve(undefined)), + } + await expect(details.read({ files: flaky, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'network' }) + expect(await details.read({ files: flaky, dir: '', repository: undefined }, ['en'])).toEqual({ status: 'none' }) + const broken: DetailFiles = { key: 'broken', read: () => Promise.reject(new TypeError('bug')), readme: () => Promise.resolve(undefined) } + await expect(details.read({ files: broken, dir: '', repository: undefined }, ['en'])).rejects.toThrow('bug') +}) + +it('forgets a document id unused for the result lifetime, and keeps one that reads or images keep using', async () => { + vi.useFakeTimers() + const details = reader({}, { ttlMs: 1000 }) + const read = (key: string) => details.read({ files: memory(key, { 'README.md': '# A', 'a.png': 'png' }), dir: '', repository: undefined }, ['en']) + const kept = await read('npm:kept@1') + const idle = await read('npm:idle@1') + if (kept.status !== 'found' || idle.status !== 'found') throw new Error('expected documents') + vi.advanceTimersByTime(600) + expect(await details.image(kept.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) + vi.advanceTimersByTime(600) + // The cached read of `kept` expired, but its image request keeps the id; `idle` went unused for longer than the lifetime. + expect(await details.image(kept.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) + expect(await details.image(idle.document, 'a.png')).toBeUndefined() + expect(await read('npm:idle@1')).toEqual(idle) + expect(await details.image(idle.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) + vi.advanceTimersByTime(500) + // A cached read marks its document used. + expect(await read('npm:idle@1')).toEqual(idle) + vi.advanceTimersByTime(700) + expect(await details.image(idle.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) // A cached read without a document marks nothing. + const empty = { files: memory('npm:empty@1', {}), dir: '', repository: undefined } as const + expect(await details.read(empty, ['en'])).toEqual({ status: 'none' }) + expect(await details.read(empty, ['en'])).toEqual({ status: 'none' }) +}) + +it('reads images beside a document and refuses other paths', async () => { + const files = memory('npm:a@1', { + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'guide/DETAIL.md': '# Detail', 'guide/img/a.png': 'png', 'logo.SVG': '', 'guide/notes.txt': 'text', 'guide/big.gif': 'x'.repeat(20), + 'guide/a b.webp': 'webp', + }) + const details = reader() + const detail = await details.read({ files, dir: '', repository: undefined }, ['en']) + if (detail.status !== 'found') throw new Error('expected a document') + expect(await details.image(detail.document, 'img/a.png?raw=1#x')).toEqual({ bytes: new TextEncoder().encode('png'), mediaType: 'image/png' }) + expect(await details.image(detail.document, '/logo.SVG')).toMatchObject({ mediaType: 'image/svg+xml' }) + expect(await details.image(detail.document, './a%20b.webp')).toMatchObject({ mediaType: 'image/webp' }) + for (const path of ['../../etc/passwd.png', 'notes.txt', 'img/missing.png']) { + expect(await details.image(detail.document, path)).toBeUndefined() + } + await expect(details.image(detail.document, 'big.gif')).rejects.toMatchObject({ reason: 'too-large' }) + expect(await details.image('unknown', 'img/a.png')).toBeUndefined() + const root = await details.read({ files: memory('npm:b@1', { 'README.md': '# B', 'b.png': 'b' }), dir: '', repository: undefined }, ['en']) + if (root.status !== 'found') throw new Error('expected a document') + expect(await details.image(root.document, 'b.png')).toMatchObject({ mediaType: 'image/png' }) +}) diff --git a/packages/boot/plugin-market/tests/detail-remote.spec.ts b/packages/boot/plugin-market/tests/detail-remote.spec.ts new file mode 100644 index 0000000000..ddcf9d1333 --- /dev/null +++ b/packages/boot/plugin-market/tests/detail-remote.spec.ts @@ -0,0 +1,184 @@ +/** The detail Remote methods and the image route of the plugin market service. */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context, Service } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest' +import { DetailReader } from '../src/detail.ts' +import PluginMarket from '../src/index.ts' +import { RepositoryCache } from '../src/repository-cache.ts' +import { parseIndexBody } from '../src/parse.ts' + +vi.mock('../src/parse-thread.ts', () => ({ + parseIndexInWorker: async (body: Uint8Array, url: string) => await Promise.resolve(parseIndexBody(body, url)), +})) + +const INDEX = 'https://market.example/index.json' +const COMMIT = 'b'.repeat(40) +let home: string + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-remote-')) + // Every repository of these tests is public; no test runs Git against a network host. + vi.spyOn(RepositoryCache.prototype, 'isPublic').mockResolvedValue(true) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(home, { recursive: true, force: true }) +}) + +/** Serve fixed bodies by URL; any other URL answers 404. */ +function serve(routes: Record) { + const fetch = vi.fn((url: string) => { + const body = routes[url] + if (body instanceof Error) return Promise.reject(body) + if (body === undefined) return Promise.resolve(new Response('missing', { status: 404 })) + return Promise.resolve(new Response(typeof body === 'string' ? body : JSON.stringify(body))) + }) + vi.stubGlobal('fetch', fetch) + return fetch +} + +const index = { + format: 1, title: 'Market', plugins: [ + { + name: 'from-git', repository: { url: 'https://github.com/o/mono', directory: 'other' }, + versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/r', commit: COMMIT, path: 'pkg' }, engines: { dsh: '*' } }], + }, + { + name: 'from-git-root', repository: { url: 'https://example.com/only' }, + versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/root', commit: COMMIT }, engines: { dsh: '*' } }], + }, + { + name: 'from-git-self', repository: { url: 'https://github.com/o/self', directory: 'pkg' }, + versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/self', commit: COMMIT, path: 'pkg' }, engines: { dsh: '*' } }], + }, + { name: 'bare', versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'npm' }, engines: { dsh: '*' } }] }, + { + name: 'from-npm', versions: [ + { version: '2.0.0', publishTimestamp: 1, source: { type: 'npm' }, engines: { dsh: '*' } }, + { version: '1.0.0', publishTimestamp: 1, source: { type: 'npm', registry: 'https://npm.corp.example/' }, engines: { dsh: '*' } }, + { version: '0.9.0', publishTimestamp: 1, source: { type: 'npm', registry: 'https://registry.npmmirror.com/' }, engines: { dsh: '*' } }, + ], + repository: { url: 'https://github.com/o/npm' }, + }, + ], +} + +async function mount(options: { profile?: boolean; connection?: boolean } = {}) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + class Manager extends Service { + constructor(inner: Context) { super(inner, 'pluginManager') } + listBundles() { return Promise.resolve([]) } + listVersionExemptions() { return { exemptions: {}, warnings: [] } } + } + await ctx.plugin(Manager) + if (options.profile === true) { + ctx.provide('profileContext', { + name: 'test', startedBundles: [], dir: join(home, 'profile'), patchPath: join(home, 'profile', 'cordis.patch.yml'), + installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + }) + } + const routes = new Map Promise>() + if (options.connection === true) { + class Connection extends Service { + readonly fetch = { + register: (route: { path: string; fetch: (request: Request) => Promise }) => { + routes.set(route.path, route.fetch) + return () => Promise.resolve(void routes.delete(route.path)) + }, + } + + constructor(inner: Context) { super(inner, 'connection') } + } + await ctx.plugin(Connection) + } + await ctx.plugin(PluginMarket, { indexes: [INDEX], ...options.profile === true ? {} : { detailCacheDir: join(home, 'cache') } }) + return { market: ctx.pluginMarket, routes } +} + +const failureOf = async (promise: Promise) => remoteErrorOf(await promise.catch((error: unknown) => error)) + +it('requires a clone cache directory outside a profile', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + class Manager extends Service { + constructor(inner: Context) { super(inner, 'pluginManager') } + } + await ctx.plugin(Manager) + await expect(ctx.plugin(PluginMarket, { indexes: [] })).rejects.toThrow('detailCacheDir must be an absolute path') +}) + +it('reads market entries from their version source and the entry repository', async () => { + const requests = serve({ + [INDEX]: index, + [`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/README.md`]: '# Git', + [`https://raw.githubusercontent.com/o/root/${COMMIT}/README.md`]: '# Root', + 'https://cdn.jsdelivr.net/npm/from-npm@2.0.0/README.md': '# Npm', + 'https://raw.githubusercontent.com/o/npm/HEAD/README.md': '# Npm repository', + }) + const { market } = await mount({ profile: true }) + expect(await market.readEntryDetail(INDEX, 'from-git', '1.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Git' }) + expect(await market.readEntryDetail(INDEX, 'from-git-root', '1.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Root' }) + expect(await market.readEntryDetail(INDEX, 'bare', '1.0.0', 'en')).toEqual({ status: 'none' }) + // The entry's repository is the source directory just read, so its default branch is not read again. + expect(await market.readEntryDetail(INDEX, 'from-git-self', '1.0.0', 'en')).toEqual({ status: 'none' }) + expect(requests.mock.calls.map(([url]) => url).filter(url => url.includes('/o/self/HEAD/'))).toEqual([]) + expect(await market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Npm' }) + // A private registry's package files are not on the public CDNs; only the entry's repository is read. + expect(await market.readEntryDetail(INDEX, 'from-npm', '1.0.0', 'en')).toMatchObject({ origin: 'repository', markdown: '# Npm repository' }) + expect(await market.readEntryDetail(INDEX, 'from-npm', '0.9.0', 'en')).toMatchObject({ origin: 'repository' }) + expect(await failureOf(market.readEntryDetail(INDEX, 'from-npm', '3.0.0', 'en'))) + .toMatchObject({ code: 'plugin-market/unknown-plugin', details: { name: 'from-npm', version: '3.0.0' } }) + expect(await failureOf(market.readEntryDetail(INDEX, 'absent', '1.0.0', 'en'))).toMatchObject({ code: 'plugin-market/unknown-plugin' }) + expect(await failureOf(market.readEntryDetail(INDEX, 'absent', '1.0.0', '!'))).toMatchObject({ code: 'plugin-market/invalid-query' }) + serve({ [INDEX]: index, 'https://registry.npmmirror.com/from-npm/2.0.0/files/README.md': '# Mirror' }) + const { market: mirrored } = await mount() + expect(await mirrored.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en', { npmFileSources: ['npmmirror'] })).toMatchObject({ markdown: '# Mirror' }) +}) + +it('reports unreadable sources as detail-unavailable and keeps other failures', async () => { + serve({ [INDEX]: index, 'https://cdn.jsdelivr.net/npm/from-npm@2.0.0/locale/en.json': new Error('offline') }) + const { market } = await mount() + await market.search(INDEX, { sort: { kind: 'index' }, offset: 0, limit: 1 }) + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + expect(await failureOf(market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en'))) + .toMatchObject({ code: 'plugin-market/detail-unavailable', details: { reason: 'network' } }) + const read = vi.spyOn(DetailReader.prototype, 'read').mockRejectedValueOnce(new TypeError('bug')) + try { + await expect(market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en')).rejects.toThrow('bug') + } finally { + read.mockRestore() + } +}) + +it('serves images beside a read document and maps failures to statuses', async () => { + serve({ + [INDEX]: index, + [`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/README.md`]: '# Git ![a](a.png)', + [`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/a.png`]: 'png', + }) + const { market, routes } = await mount({ connection: true }) + const detail = await market.readEntryDetail(INDEX, 'from-git', '1.0.0', 'en') + if (detail.status !== 'found') throw new Error('expected a document') + const route = routes.get('/api/plugin-market/detail-file')! + const get = (query: string) => route(new Request(`http://dsh.local/api/plugin-market/detail-file${query}`)) + const ok = await get(`?document=${detail.document}&path=a.png`) + expect([ok.status, ok.headers.get('content-type'), ok.headers.get('x-content-type-options'), await ok.text()]) + .toEqual([200, 'image/png', 'nosniff', 'png']) + expect((await get(`?document=${detail.document}`)).status).toBe(400) + expect((await get(`?document=${detail.document}&path=missing.png`)).status).toBe(404) + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('x'.repeat(10), { headers: { 'content-length': String(6 * 1024 * 1024) } })))) + expect((await get(`?document=${detail.document}&path=large.png`)).status).toBe(413) + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + expect((await get(`?document=${detail.document}&path=b.png`)).status).toBe(502) + const image = vi.spyOn(DetailReader.prototype, 'image').mockRejectedValueOnce(new TypeError('bug')) + try { + await expect(get(`?document=${detail.document}&path=c.png`)).rejects.toThrow('bug') + } finally { + image.mockRestore() + } +}) diff --git a/packages/boot/plugin-market/tests/market-parse-failure.spec.ts b/packages/boot/plugin-market/tests/market-parse-failure.spec.ts index d17c85905a..f52fa177ca 100644 --- a/packages/boot/plugin-market/tests/market-parse-failure.spec.ts +++ b/packages/boot/plugin-market/tests/market-parse-failure.spec.ts @@ -3,6 +3,9 @@ import { Context, Service } from '@deepseek-ai/cordis' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import { expect, it, onTestFinished, vi } from 'vitest' +/** Clone cache directory of services whose tests read no repository; nothing creates it. */ +const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache' + vi.mock('../src/parse-thread.ts', () => ({ parseIndexInWorker: () => Promise.reject(new Error('parser crashed')) })) const { default: PluginMarket } = await import('../src/index.ts') @@ -18,7 +21,7 @@ it('reports a parse Worker failure as plugin-market/fetch-failed', async () => { listVersionExemptions() { return { exemptions: {}, warnings: [] } } } await ctx.plugin(Manager) - await ctx.plugin(PluginMarket, { indexes: ['https://market.example/index.json'] }) + await ctx.plugin(PluginMarket, { indexes: ['https://market.example/index.json'], detailCacheDir: UNUSED_CACHE }) const failure = await ctx.pluginMarket.search('https://market.example/index.json', { sort: { kind: 'index' }, offset: 0, limit: 1 }) .then(() => { throw new Error('resolved') }, (error: unknown) => remoteErrorOf(error)) expect(failure).toMatchObject({ code: 'plugin-market/fetch-failed', details: { reason: 'parser crashed' } }) diff --git a/packages/boot/plugin-market/tests/market.spec.ts b/packages/boot/plugin-market/tests/market.spec.ts index c6b48f80b6..2bd2e998b5 100644 --- a/packages/boot/plugin-market/tests/market.spec.ts +++ b/packages/boot/plugin-market/tests/market.spec.ts @@ -7,6 +7,9 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import PluginMarket, { type Config, type MarketQuery } from '../src/index.ts' import { parseIndexBody } from '../src/parse.ts' +/** Clone cache directory of services whose tests read no repository; nothing creates it. */ +const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache' + // Parse in-process; parse-worker.spec.ts runs the real Worker. vi.mock('../src/parse-thread.ts', () => ({ parseIndexInWorker: async (body: Uint8Array, url: string, signal: AbortSignal) => { @@ -21,7 +24,7 @@ const URL_B = 'https://other.example/index.json' afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() }) function bundle(name: string, fields: Partial): BundleInfo { - return { name, official: false, availability: 'profile', enabled: true, installed: true, optional: false, removable: true, rows: [], overrides: [], ...fields } + return { name, official: false, availability: 'profile', enabled: true, installed: true, optional: false, removable: true, updatable: false, rows: [], overrides: [], ...fields } } function index(plugins: unknown[] = []): unknown { @@ -66,7 +69,7 @@ async function mount(config: Partial<{ indexes: string[]; fetchTimeoutMs: number captured = resolved } } - await ctx.plugin(CapturingMarket, { indexes: [URL_A], ...config }) + await ctx.plugin(CapturingMarket, { indexes: [URL_A], detailCacheDir: UNUSED_CACHE, ...config }) if (captured === undefined) throw new Error('market did not mount') return { ctx, market: ctx.pluginMarket, config: captured } } diff --git a/packages/boot/plugin-market/tests/repository-cache.spec.ts b/packages/boot/plugin-market/tests/repository-cache.spec.ts new file mode 100644 index 0000000000..bb57767bc3 --- /dev/null +++ b/packages/boot/plugin-market/tests/repository-cache.spec.ts @@ -0,0 +1,295 @@ +/** Partial clones of real local repositories: reads, README lookup, limits, eviction, and Git failures. */ +import { execFileSync } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { DetailFailure } from '@deepseek-ai/dsh-plugin-detail' +import { RepositoryCache, type RepositoryCacheOptions } from '../src/repository-cache.ts' + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + + +let root: string +const lifetime = new AbortController() +const signal = new AbortController().signal +const text = (bytes: Uint8Array | undefined): string => new TextDecoder().decode(bytes) + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-repositories-')) + // Git reads no configuration of the developer running the tests. + mkdirSync(join(root, 'home')) + vi.stubEnv('HOME', join(root, 'home')) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'home')) +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(root, { recursive: true, force: true }) +}) + +function git(cwd: string, ...args: string[]): string { + // Fixture repositories ignore inherited Git overrides, such as a hook's `GIT_DIR`, and every user or system configuration. + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_'))) + return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...args], { + cwd, encoding: 'utf8', + env: { ...env, HOME: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'home'), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + }).trim() +} + +/** The cache directory name of a repository URL. */ +function cloneName(url: string): string { + return createHash('sha256').update(url).digest('hex').slice(0, 24) +} + +/** Bytes of the regular files below a directory. */ +function dirSize(dir: string): number { + return readdirSync(dir, { recursive: true, encoding: 'utf8' }) + .map(name => lstatSync(join(dir, name))) + .reduce((sum, entry) => sum + (entry.isFile() ? entry.size : 0), 0) +} + +/** A repository with `files` committed on its default branch, served as a `file:` URL. */ +function repository(name: string, files: Record): { url: string; commit: string; dir: string } { + const dir = join(root, 'sources', name) + mkdirSync(dir, { recursive: true }) + git(dir, 'init', '--quiet', '--initial-branch=main') + // A partial fetch from a local repository needs the server side to accept object filters. + git(dir, 'config', 'uploadpack.allowFilter', 'true') + git(dir, 'config', 'uploadpack.allowAnySHA1InWant', 'true') + for (const [path, contents] of Object.entries(files)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }) + writeFileSync(join(dir, path), contents) + } + git(dir, 'add', '-A') + git(dir, 'commit', '--quiet', '-m', 'init') + return { url: pathToFileURL(dir).href, commit: git(dir, 'rev-parse', 'HEAD'), dir } +} + +function cache(options: Partial = {}): RepositoryCache { + return new RepositoryCache({ + dir: join(root, 'cache'), maxRepositoryBytes: 10 * 1024 * 1024, maxCacheBytes: 100 * 1024 * 1024, + timeoutMs: 30_000, ttlMs: 60_000, transports: ['file', 'ssh'], lifetime: lifetime.signal, sizePollMs: 1, ...options, + }) +} + +it('reads files at a pinned commit and the default branch, fetching contents only when read', async () => { + const source = repository('a', { 'packages/p/README.md': '# P', 'packages/p/img/a.png': 'png', 'big.bin': 'x'.repeat(4096) }) + const repositories = cache() + expect(new TextDecoder().decode(await repositories.read(source.url, source.commit, 'packages/p/img/a.png', 100, signal))).toBe('png') + expect(await repositories.read(source.url, source.commit, 'packages/p/missing.png', 100, signal)).toBeUndefined() + expect(await repositories.read(source.url, source.commit, 'packages/p', 100, signal)).toBeUndefined() + await expect(repositories.read(source.url, source.commit, 'big.bin', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + // A file over its read's size leaves the clone usable. + expect(readdirSync(join(root, 'cache'))).toHaveLength(1) + writeFileSync(join(source.dir, 'packages/p/README.md'), '# P2') + git(source.dir, 'commit', '--quiet', '-am', 'second') + expect(new TextDecoder().decode((await repositories.readme(source.url, undefined, 'packages/p', 100, signal))?.bytes)).toBe('# P2') + writeFileSync(join(source.dir, 'packages/p/README.md'), '# P3') + git(source.dir, 'commit', '--quiet', '-am', 'third') + // The default branch commit stays cached for the TTL. + expect(new TextDecoder().decode((await repositories.readme(source.url, undefined, 'packages/p', 100, signal))?.bytes)).toBe('# P2') + expect(await repositories.readme(source.url, source.commit, 'packages/p/img', 100, signal)).toBeUndefined() + expect(new TextDecoder().decode((await repositories.readme(source.url, source.commit, 'packages/p', 100, signal))?.bytes)).toBe('# P') + await repositories.settled() +}) + +it('picks the README of a root directory by name order, in any letter case', async () => { + const source = repository('b', { 'readme.markdown': 'second', 'Readme.md': 'first', 'docs/readme': 'plain' }) + const repositories = cache() + expect(await repositories.readme(source.url, source.commit, '', 100, signal)).toMatchObject({ path: 'Readme.md' }) + expect(await repositories.readme(source.url, source.commit, 'docs', 100, signal)).toMatchObject({ path: 'docs/readme' }) +}) + +it('asks the smart HTTP endpoint without credentials whether a repository is public, and caches the answer', async () => { + const answers: Record = { + 'https://git.example/public.git/info/refs?service=git-upload-pack': new Response('refs'), + 'https://git.example/private.git/info/refs?service=git-upload-pack': new Response('no', { status: 401 }), + 'https://git.example/moved.git/info/refs?service=git-upload-pack': new Response(null, { status: 302, headers: { location: '/login' } }), + 'https://git.example/broken.git/info/refs?service=git-upload-pack': new Response('no', { status: 500 }), + 'https://git.example/down.git/info/refs?service=git-upload-pack': new Error('offline'), + } + const fetch = vi.fn((url: string, init: RequestInit) => { + expect(init.redirect).toBe('manual') + const answer = answers[url] + return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer as Response) + }) + vi.stubGlobal('fetch', fetch) + const repositories = cache() + const [first, second] = await Promise.all([repositories.isPublic('https://git.example/public.git'), repositories.isPublic('https://git.example/public.git')]) + expect([first, second]).toEqual([true, true]) + expect(await repositories.isPublic('https://git.example/public.git')).toBe(true) + expect(await repositories.isPublic('https://git.example/private.git')).toBe(false) + expect(await repositories.isPublic('https://git.example/moved.git')).toBe(false) + await expectRejection(repositories.isPublic('https://git.example/broken.git'), { reason: 'network' }, 'HTTP 500') + await expectRejection(repositories.isPublic('https://git.example/down.git'), { reason: 'network' }, 'offline') + expect(fetch).toHaveBeenCalledTimes(5) +}) + +it('reports an unreachable host as a network failure, a failed local command as a plain error, and a Git that cannot run', async () => { + vi.stubEnv('GIT_SSH_COMMAND', 'ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5') + await expectRejection(cache().read('ssh://127.0.0.1:1/owner/repo.git', undefined, 'README.md', 100, signal), { reason: 'network' }, 'git fetch') + const source = repository('t', { 'README.md': 'x' }) + const repositories = cache() + await repositories.read(source.url, source.commit, 'README.md', 100, signal) + // A clone whose commit refs cannot be written fails a local command after a successful fetch; that says nothing about + // the repository, and the clone is deleted. + const [clone] = readdirSync(join(root, 'cache')) + rmSync(join(root, 'cache', clone as string, 'refs', 'dsh'), { recursive: true }) + writeFileSync(join(root, 'cache', clone as string, 'refs', 'dsh'), 'not a directory') + writeFileSync(join(source.dir, 'README.md'), 'y') + git(source.dir, 'commit', '--quiet', '-am', 'next') + const broken = repositories.read(source.url, git(source.dir, 'rev-parse', 'HEAD'), 'README.md', 100, signal) + await expect(broken).rejects.toThrow('git update-ref') + await expect(broken).rejects.not.toBeInstanceOf(DetailFailure) + expect(readdirSync(join(root, 'cache'))).toEqual([]) + // Git that cannot answer `git --version` within the deadline cannot run. + const other = repository('t2', { 'README.md': 'x' }) + await expectRejection(cache({ timeoutMs: 1 }).read(other.url, other.commit, 'README.md', 100, signal), { reason: 'git-unavailable' }, 'timed out') + vi.stubEnv('PATH', join(root, 'no-git')) + const missing = cache().read(other.url, other.commit, 'README.md', 100, signal) + await expectRejection(missing, { reason: 'git-unavailable' }, 'git --version') + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('reports a repository Git cannot fetch as denied', async () => { + const repositories = cache() + const missing = pathToFileURL(join(root, 'missing')).href + const failure = await repositories.read(missing, undefined, 'README.md', 100, signal).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DetailFailure) + expect(failure).toMatchObject({ reason: 'denied' }) + expect(readdirSync(join(root, 'cache'))).toEqual([]) + const source = repository('d', { 'README.md': 'x' }) + await expect(repositories.read(source.url, 'f'.repeat(40), 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'denied' }) + await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).resolves.toBeDefined() +}) + +it('stops a fetch once the repository grows past its limit, and deletes the repository', async () => { + const source = repository('e', { 'README.md': 'x' }) + // The empty repository already exceeds one byte, so the first size check stops Git. + const repositories = cache({ maxRepositoryBytes: 1 }) + await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('refuses a repository that a completed fetch left past its limit', async () => { + // Distinct files make distinct trees, so the fetch adds one tree object per directory. + const source = repository('e2', Object.fromEntries(Array.from({ length: 1000 }, (_value, index) => [`dir${String(index)}/f.txt`, String(index)]))) + const repositories = cache({ maxRepositoryBytes: 4096, sizePollMs: 3_600_000 }) + await expect(repositories.read(source.url, source.commit, 'dir0/f.txt', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('deletes least recently used repositories beyond the cache size and stale staging directories', async () => { + const first = repository('f', { 'README.md': 'first' }) + const second = repository('g', { 'README.md': 'second' }) + const third = repository('h', { 'README.md': 'third' }) + // The total holds one of these clones, not two. + const probe = cache({ dir: join(root, 'probe') }) + await probe.read(first.url, first.commit, 'README.md', 100, signal) + const one = dirSize(join(root, 'probe')) + const repositories = cache({ maxCacheBytes: Math.floor(one * 2.5) }) + mkdirSync(join(root, 'cache', '.init-stale'), { recursive: true }) + utimesSync(join(root, 'cache', '.init-stale'), new Date(0), new Date(0)) + mkdirSync(join(root, 'cache', '.init-running')) + writeFileSync(join(root, 'cache', 'stray.txt'), 'not a repository') + // An entry that disappears while it is measured no longer counts. + symlinkSync(join(root, 'gone'), join(root, 'cache', 'vanished')) + await repositories.read(first.url, first.commit, 'README.md', 100, signal) + expect(readdirSync(join(root, 'cache')).filter(name => name.startsWith('.init-'))).toEqual(['.init-running']) + rmSync(join(root, 'cache', '.init-running'), { recursive: true }) + rmSync(join(root, 'cache', 'vanished')) + rmSync(join(root, 'cache', 'stray.txt')) + await repositories.read(second.url, second.commit, 'README.md', 100, signal) + await repositories.settled() + expect(readdirSync(join(root, 'cache'))).toHaveLength(2) + await repositories.read(third.url, third.commit, 'README.md', 100, signal) + await repositories.settled() + // The least recently used clone goes first; the two newer ones fit. + expect(readdirSync(join(root, 'cache')).sort()).toEqual([second.url, third.url].map(cloneName).sort()) + // A cached commit is read again without a fetch. + await repositories.read(third.url, third.commit, 'README.md', 100, signal) + expect(existsSync(join(root, 'cache'))).toBe(true) +}) + +it('completes concurrent fetches of different repositories that each evict the other', async () => { + const a = repository('ca', { 'README.md': 'a' }) + const b = repository('cb', { 'README.md': 'b' }) + const repositories = cache({ maxCacheBytes: 1 }) + const reads = await Promise.all([a, b].map(source => repositories.readme(source.url, undefined, '', 100, signal))) + expect(reads.map(read => text(read?.bytes))).toEqual(['a', 'b']) + await repositories.settled() + expect(readdirSync(join(root, 'cache')).length).toBeLessThanOrEqual(1) +}) + +it('returns a blob whose last byte is a newline intact', async () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]) + const source = repository('nl', { 'a.png': bytes }) + expect(await cache().read(source.url, source.commit, 'a.png', 100, signal)).toEqual(Buffer.from(bytes)) +}) + +it('enforces the cache total after a blob read, deleting the repository in use after its read when it alone is too large', async () => { + const source = repository('blob', { 'README.md': 'r', 'big.bin': randomBytes(200_000) }) + const repositories = cache() + await repositories.read(source.url, source.commit, 'README.md', 100, signal) + await repositories.settled() + const kept = readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock')) + expect(kept).toHaveLength(1) + // The clone without contents fits; reading the large blob downloads it and grows the repository past the total. + const small = cache({ maxCacheBytes: 150_000 }) + expect((await small.read(source.url, source.commit, 'big.bin', 300_000, signal))?.byteLength).toBe(200_000) + // The deletion waits for the read that holds the repository, then runs. + await new Promise((resolve) => { setTimeout(resolve, 50) }) + await small.settled() + expect(readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock'))).toEqual([]) +}) + +it('serializes one repository across caches that share the directory, as two processes on one profile do', async () => { + const source = repository('shared', { 'README.md': 'shared' }) + const [first, second] = [cache(), cache()] + const reads = await Promise.all([first, second].map(each => each.read(source.url, source.commit, 'README.md', 100, signal))) + expect(reads.map(read => text(read))).toEqual(['shared', 'shared']) + await Promise.all([first.settled(), second.settled()]) + expect(readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock'))).toHaveLength(1) +}) + +it('stops Git when the caller cancels or the service is disposed', async () => { + const source = repository('i', { 'README.md': 'x' }) + const cancelled = new AbortController() + cancelled.abort(new Error('cancelled')) + await expect(cache().read(source.url, source.commit, 'README.md', 100, cancelled.signal)).rejects.toThrow('cancelled') + const disposed = new AbortController() + disposed.abort(new Error('disposed')) + await expect(cache({ lifetime: disposed.signal }).read(source.url, source.commit, 'README.md', 100, signal)).rejects.toThrow('disposed') + // Neither a repository nor its staging directory remains. + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('reports file contents Git can no longer fetch and deletes the clone, ignoring an inherited GIT_DIR', async () => { + vi.stubEnv('GIT_DIR', join(root, 'elsewhere')) + const source = repository('j', { 'README.md': 'x', 'b.png': 'b' }) + const repositories = cache() + await repositories.read(source.url, source.commit, 'README.md', 100, signal) + rmSync(source.dir, { recursive: true, force: true }) + await expectRejection(repositories.read(source.url, source.commit, 'b.png', 100, signal), { reason: 'denied' }, 'git cat-file b.png') + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('fetches a cached default-branch commit again after eviction, and reports a clone that can no longer fetch it', async () => { + const first = repository('k', { 'README.md': 'first' }) + const second = repository('l', { 'README.md': 'second' }) + const repositories = cache({ maxCacheBytes: 1 }) + await repositories.read(first.url, undefined, 'README.md', 100, signal) + await repositories.read(second.url, undefined, 'README.md', 100, signal) + expect(text(await repositories.read(first.url, undefined, 'README.md', 100, signal))).toBe('first') + const [clone] = readdirSync(join(root, 'cache')) + rmSync(join(root, 'cache', clone as string, 'objects', 'pack'), { recursive: true, force: true }) + rmSync(first.dir, { recursive: true, force: true }) + await expectRejection(repositories.read(first.url, undefined, 'README.md', 100, signal), { reason: 'denied' }, 'git fetch') +}) diff --git a/packages/boot/plugin-market/tests/tools.spec.ts b/packages/boot/plugin-market/tests/tools.spec.ts index ae1b929b15..14c5c7a479 100644 --- a/packages/boot/plugin-market/tests/tools.spec.ts +++ b/packages/boot/plugin-market/tests/tools.spec.ts @@ -7,6 +7,9 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import PluginMarket from '../src/index.ts' import * as tool from '../src/tools.ts' +/** Clone cache directory of services whose tests read no repository; nothing creates it. */ +const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache' + const URL_A = 'https://market.example/index.json' afterEach(() => { vi.unstubAllGlobals() }) @@ -26,7 +29,7 @@ async function fixture() { listVersionExemptions() { return { exemptions: {}, warnings: [] } } } await ctx.plugin(Manager) - await ctx.plugin(PluginMarket, { indexes: [URL_A] }) + await ctx.plugin(PluginMarket, { indexes: [URL_A], detailCacheDir: UNUSED_CACHE }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const fiber = await ctx.plugin(tool) @@ -167,7 +170,7 @@ it('registers the tool only while the pluginMarket service is mounted', async () await ctx.plugin(ToolRuntime) await ctx.plugin(tool) expect(ctx.tools.get('plugin_market')).toBeUndefined() - const market = await ctx.plugin(PluginMarket, { indexes: [] }) + const market = await ctx.plugin(PluginMarket, { indexes: [], detailCacheDir: UNUSED_CACHE }) await vi.waitFor(() => { expect(ctx.tools.get('plugin_market')).toBeDefined() }) await market.dispose() await vi.waitFor(() => { expect(ctx.tools.get('plugin_market')).toBeUndefined() }) diff --git a/packages/boot/plugin-market/tsconfig.json b/packages/boot/plugin-market/tsconfig.json index 19804b2708..9a7ab10fda 100644 --- a/packages/boot/plugin-market/tsconfig.json +++ b/packages/boot/plugin-market/tsconfig.json @@ -19,6 +19,11 @@ { "path": "../../util/values" }, { "path": "../../settings/settings" }, { "path": "../../typert/protocol" }, - { "path": "../../core/tools" } + { "path": "../../core/tools" }, + { "path": "../../util/brand" }, + { "path": "../../subprocess/subprocess" }, + { "path": "../../client/connection/tsconfig.host.json" }, + { "path": "../../util/plugin-detail" }, + { "path": "../../util/atomic-write" } ] } diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index b93af7197d..62cee1bca3 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-minimal/use-this-package: - en: 39483eef4a61f212 - zh: 62d82f46a04e07fe + en: 44072e053b253924 + zh: fecb1b7986ac05d4 /deepseek-ai-dsh-sdk-minimal/understand-the-implementation: en: 5db5d8f041867958 zh: bff5ef3a2e140712 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 9accd1fec2..db70633cd0 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -38,6 +38,8 @@ Use `dsh plugin --profile sdk-minimal` to manage persistent external dependencie The profile mounts exactly one persistent shell stack: Bash on Linux and macOS, or PowerShell on Windows. Both stacks use a 300-second timeout and one owner-scoped terminal; the other platform's rows remain disabled. +[Session checkpoints](../../session/session-checkpoint-policy/README.md) flush history before each model request, top-level tool execution, and agent step. Dispatch waits for the flush and stops if persistence fails, so the first model request also waits for native file-lock initialization. The final response still depends on background writes or successful shutdown; checkpoints do not make forced termination safe for unflushed events. + Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell, `read_image`, and `working_directory` tools. ----- diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index d35655df20..dbaef50cab 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -38,6 +38,8 @@ dsh --profile sdk-minimal 该 profile 只挂载一套持久 shell:Linux 和 macOS 使用 Bash,Windows 使用 PowerShell。两套配置都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。 +[Session 检查点](../../session/session-checkpoint-policy/README.zh.md)会在每次模型请求、顶层工具执行和 agent step 之前刷盘。调度会等待刷盘完成,持久化失败时停止,因此第一次模型请求也会等待原生文件锁初始化。最后一段回复仍依赖后台写入或成功关闭来保存;检查点不能保证强制终止时尚未刷盘的事件不丢失。 + 与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell、`read_image` 与 `working_directory` 三个工具。 ----- diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 79922e7113..10b1fa0b5a 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -170,3 +170,6 @@ config: root: !!js dshHomePath('sessions') compression: none + + - id: session-checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index d53a316474..f5a82a3662 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-sdk-app": "workspace:*", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-log-deepseek": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index f4e24e5bc5..ce122661ae 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -61,6 +61,7 @@ describe('dsh-sdk-minimal bundle', () => { ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ['session-checkpoint-policy', '@deepseek-ai/dsh-session-checkpoint-policy'], ]) expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' }) expect(rows.find(row => row.id === 'sdk-jsonrpc-server')).toMatchObject({ diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index a784096461..acf99f22d2 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -57,7 +57,10 @@ export class FileUploads extends TypertRemoteService { static inject = ['agents', 'attachments', 'commands', 'connection'] private readonly stagedFiles = new WeakMap>() - private agentResolver: AgentResolver | undefined + // Inside a method called through `ctx.fileUploads`, Cordis returns a new proxy + // for every function-valued property read from `this`, so `===` against the + // registered resolver would always be false; a plain object is returned as is. + private readonly agentResolution: { resolver: AgentResolver | undefined } = { resolver: undefined } /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ constructor(ctx: Context) { @@ -87,10 +90,10 @@ export class FileUploads extends TypertRemoteService { * @returns disposer removing this resolver. */ registerAgentResolver(resolve: AgentResolver): () => void { - if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered') - this.agentResolver = resolve + if (this.agentResolution.resolver !== undefined) throw new Error('file-upload: Agent resolver is already registered') + this.agentResolution.resolver = resolve return () => { - if (this.agentResolver === resolve) this.agentResolver = undefined + if (this.agentResolution.resolver === resolve) this.agentResolution.resolver = undefined } } @@ -212,7 +215,7 @@ export class FileUploads extends TypertRemoteService { private async resolveAgent(sessionId: SessionId): Promise { const live = this.ctx.agents.get(sessionId) if (live !== undefined) return live - const resolver = this.agentResolver + const resolver = this.agentResolution.resolver if (resolver === undefined) { throw new RemoteError('session/not-found', `session "${sessionId}" is not attached`, { sessionId }) } diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index 6eb4ac4520..33235c3daa 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -121,7 +121,7 @@ const visibleFoldCases = [ { name: 'small viewport', earlier: 200, steps: 900, body: 100, viewport: 300, top: 900, input: 30, target: 200, room: 170 }, ] as const -function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean) { +function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean, finalSiblingGrowth = 0) { let inputHeight = 0 let submission = 0 let top: number = test.top @@ -146,7 +146,7 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea const rowHeight = () => row.hidden ? 0 : (row.style.transition !== '' && row.style.transition !== 'none') || row.style.height === '' ? test.steps : Number.parseFloat(row.style.height) const room = () => Number.parseFloat(spacer.style.height) || 0 - const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight + const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight + (row.hidden ? finalSiblingGrowth : 0) const floor = () => Math.max(0, contentHeight() + room() - test.viewport) const scrollTo = vi.fn((options: ScrollToOptions) => { scroller.scrollTop = options.top ?? scroller.scrollTop @@ -215,6 +215,22 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea } describe.each([false, true])('visible input-fold matrix (reduced=%s)', (reduced) => { + it.each(['larger input', 'T/B'])('lands on final sibling spacing in the closing frame (%s)', async (name) => { + const test = visibleFoldCases.find(test => test.name === name)! + const h = mountVisibleFold(test, reduced, 4) + h.submit() + await act(async () => { await Promise.resolve() }) + for (let frame = 0; frame < 24 && !h.row.hidden; frame++) { + act(() => { vi.advanceTimersByTime(16) }) + } + expect(h.row.hidden).toBe(true) + const target = test.target + (test.room === 0 ? 4 : 0) + expect(h.scroller.scrollTop).toBe(target) + expect(h.room()).toBe(Math.max(0, test.room - 4)) + act(() => { vi.advanceTimersByTime(500) }) + expect(h.scroller.scrollTop).toBe(target) + }) + it.each(visibleFoldCases)('$name lands with only the required visible room', async (test) => { const h = mountVisibleFold(test, reduced) const positions = [h.bodyTop()] diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index dbb4836fb5..6800eabe84 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,11 +15,14 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 8d8c0fcd9320761a - zh: 087473d318680403 + en: 0d126503459846d5 + zh: c380b8e975a76913 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb +/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/updating-a-registry-bundle: + en: 9312d8b1b381378f + zh: 8d0ae7ad26ada2ac /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: 59e12480cf0c3e70 zh: f230ea8c752475d5 @@ -30,8 +33,8 @@ en: e9da90f03691101b zh: 902cda935a4ce3c8 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points: - en: 5266d86638cc5cd7 - zh: 398d710df9806cea + en: 54d7666c630da471 + zh: d7ded257d0fd5559 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: en: 76e0908903ab8fde zh: ce6ae1cdd6aea971 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 21d56214a4..a88ae41a2d 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -33,11 +33,13 @@ The heading moves its complete action group to another line when space is insuff Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. +A bundle's page shows the bundle's detail document first among its sections, without a heading of its own: the bundle's long description in the current UI language, as Markdown with images. It is the Markdown file that `meta.detail` in the bundle's `locale/.json` names, otherwise the bundle's README, read by [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.md#bundle-detail-documents) in the [document order of `dsh-plugin-detail`](../../util/plugin-detail/README.md#document-order); the repository a bundle declares is not read. An installed bundle's document comes from its own files without network access, and an on-demand Official bundle's from the npm file CDNs at its offered version. While the document loads the section shows three skeleton lines; a bundle without a document shows no section; a failed read names its reason with **Retry**. Raw HTML is omitted, relative images load from the Host beside the document, relative links stay text, and a document taller than the section's height limit scrolls inside it. A bundle whose files are not installed, such as an on-demand Official bundle, is read from the npm file CDNs: its read first asks the Host's registry comparison and, when npmmirror answered fastest, reads it and its images from npmmirror first. An installed bundle's read does not wait for the comparison. Any profile change and a connection reset read shown documents again. + On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. -The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner. +The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. It reads the list again, then checks every updatable bundle for newer versions one after another; a failed read checks nothing. During a manual refresh, the button shows a spinner and stays disabled until all pending reads and those checks settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select the button again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner. ### Installing a bundle @@ -60,6 +62,14 @@ During preparation and download, **Cancel install** asks the Host to stop the ru When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub**, or **GitHub connection timed out** for a timeout, and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. When the install already asks npmmirror, whether picked, typed, or named by pnpm's own configuration, the button reads **Try another way** instead and returns to the same empty field with **Install guide and examples** unfolded and the registry unchanged. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself. +### Updating a registry bundle + +A third-party bundle installed from a registry, which the Host marks `updatable`, has a **Versions** section on its page below its rows. Opening the page asks the Host for [its newer versions](../../boot/plugin-manager/README.md#use-this-package) once; **Check again** asks again. The section shows one summary row: checking, up to date with the time of the last check, an update to the newest version that declares a bundle, accepts the running DSH, and is neither deprecated nor a prerelease, newer versions that need a DSH update with the DSH peers they need, or a failed check with the registry that failed last and **Retry**. A summary with newer versions unfolds into one row per version, newest first, with its dist-tags, prerelease and deprecated tags, why it cannot be installed, its publication date, and **Update** for an installable version; the installed version closes the list. Below it, the registry that answered is marked **Fallback** when an earlier registry failed, beside the time of the check and **Show prereleases**. A failed check unfolds through **Details** into every registry asked, its role, what it failed with, and pnpm's words. Bundles installed from a link, a path, git, or a tarball, and Official bundles, show no Versions section. Older versions are never offered. + +After the checks, the list toolbar says how many plugins can be updated, that all are up to date, or that some checks failed, and cards with an update show **Update available**. The detail head adds **Update to** with the offered version. + +**Update** opens a confirmation with the installed and target versions, the registry it installs from, and that a failed installation keeps or reinstalls the installed version. While the Host runs the update the dialog lists the steps it announced and stays open. The outcome says that the new version runs, that it waits for a restart with the Host's reason, that the target was refused before anything changed, that removing the installed version failed, that the target could not be installed while the installed version stayed in place, that the update failed and the previous version was reinstalled, or that the reinstallation failed too and the plugin is not installed; a failed run unfolds pnpm's output. A lost answer says that the result is unknown and the list is read again. + ### Switching a bundle A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. @@ -84,7 +94,7 @@ The bundle's patch must declare the row under that id, and the registration exis ### Detail page extension points -A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. +A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the source on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. ```tsx ignore-check ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 2d4e6f72ca..6017a51223 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -33,11 +33,13 @@ kind: "package-reference" 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 +组合包页面在其各部分中首先显示该组合包的详细介绍文档,不带单独的标题:以当前界面语言、带图片的 Markdown 显示该组合包的详细介绍。该文档是组合包 `locale/.json` 中 `meta.detail` 指定的 Markdown 文件,没有时为组合包的 README,由 [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 按 [`dsh-plugin-detail` 的文档顺序](../../util/plugin-detail/README.zh.md#document-order)读取;不读取组合包声明的仓库。已安装组合包的文档来自其自身文件,不访问网络;按需官方组合包的文档按其提供的版本从 npm 文件 CDN 读取。文档加载期间,该部分显示三行骨架屏;没有文档的组合包不显示该部分;读取失败时说明原因,并提供**重试**。原始 HTML 会被省略,相对路径的图片从 Host 上该文档旁的位置加载,相对链接保持为文本,文档高度超过该部分的高度上限时在该部分内滚动。文件未安装的组合包(例如按需官方组合包)从 npm 文件 CDN 读取:读取前先询问 Host 的注册表比较,npmmirror 响应最快时,先从 npmmirror 读取该文档及其图片。已安装组合包的读取不等待该比较。任何 profile 变化和连接重置都会重新读取已显示的文档。 + 官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 -**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 +**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。它重新读取列表,再依次检查每个可更新组合包的新版本;读取失败时不做检查。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取和这些检查结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择该按钮即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 ### 安装一个组合包 @@ -60,6 +62,14 @@ Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,超时时显示**连接 GitHub 超时**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。安装已在使用 npmmirror 时(无论是选中、手动输入,还是 pnpm 自身配置指向它),按钮改为**试试其他方式**,同样回到空的包名输入框并展开**插件安装引导和示例**,安装源保持不变。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。 +### 更新注册表组合包 + +从注册表安装、Host 标为 `updatable` 的第三方组合包,在其页面的行列表下方有 **版本** 区块。打开页面时向 Host 询问一次[它的新版本](../../boot/plugin-manager/README.zh.md#use-this-package);**重新检查** 再问一次。区块显示一行摘要:正在检查;已是最新及上次检查时间;可更新到声明组合包、接受当前 DSH、未弃用且非预发布的最新版本;新版本需要更新 DSH 及其所需的 DSH peer;或检查失败、最后失败的注册表及 **重试**。有新版本的摘要可展开为每个版本一行,从新到旧,带 dist-tag、预发布与已弃用标签、无法安装的原因、发布日期,以及可安装版本的 **更新**;列表以已安装版本结尾。其下显示作答的注册表,前面的注册表失败时标为 **备选源**,旁边是检查时间和 **显示预发布版本**。检查失败时可通过 **详情** 展开问过的每个注册表、其角色、失败类型和 pnpm 的原文。从链接、路径、git 或压缩包安装的组合包以及官方组合包不显示版本区块。不会提供更旧的版本。 + +检查结束后,列表工具栏说明有几个插件可更新、全部已是最新,或部分检查失败,有更新的卡片显示 **可更新**。详情页头部增加带目标版本的 **更新到**。 + +**更新** 打开确认框,显示已安装版本与目标版本、安装来源,以及安装失败时会保留或重新安装已安装版本。Host 运行更新期间,对话框列出它通告的步骤并保持打开。结果说明新版本已开始运行、需要重启才生效及 Host 给出的原因、目标版本在任何改动前被拒绝、移除已安装版本失败、目标版本未能安装而已安装版本保持不变、更新失败且已重新安装原版本,或重新安装也失败、插件未安装;失败的运行可展开 pnpm 输出。答复丢失时说明结果未知,并重新读取列表。 + ### 切换一个组合包 组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 @@ -84,7 +94,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### 详情页扩展点 -对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 +对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在来源信息之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 ```tsx ignore-check ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ diff --git a/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx new file mode 100644 index 0000000000..90a2f07045 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx @@ -0,0 +1,58 @@ +/** A bundle's long description on its page: the Markdown document the Host reads for it. */ +import { useEffect, useMemo, type ReactNode } from 'react' +import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { bundleDetailKey, type BundleDetailFailure, type BundleDetailsState } from './detail-store.ts' + +type Translate = PropsLocale<'pluginManager'>['t'] + +const FAILURE_KEYS = { + 'too-large': 'detailTooLarge', + 'network': 'detailNetwork', + 'denied': 'detailFailed', + 'git-unavailable': 'detailFailed', + 'invalid': 'detailInvalid', + 'other': 'detailFailed', +} as const satisfies Record + +/** What the section reads and calls; the page passes them from its injected face. */ +export interface BundleDetailSectionProps { + readonly name: string + readonly version: string | undefined + /** Whether the bundle's files are unavailable, so the Host reads its offered version from the npm file CDNs. */ + readonly fromNpm: boolean + readonly language: string + readonly details: BundleDetailsState + readonly t: Translate + readonly load: (name: string, version: string | undefined, language: string, fromNpm: boolean) => void + readonly retry: (name: string, version: string | undefined, language: string, fromNpm: boolean) => void + readonly image: (name: string, document: string, destination: string, fromNpm: boolean) => string | undefined +} + +/** + * Read and render a bundle's detail document in the active language; relative images load from the Host beside it. + * @param props - the bundle, the language, the reads, and the page's callbacks. + * @returns the section, or null when the bundle has no document. + */ +export function BundleDetailSection(props: BundleDetailSectionProps): ReactNode { + const { name, version, fromNpm, language, details, t, load, retry, image } = props + const key = bundleDetailKey(name, version, language) + const state = details[key] + // The key names the read completely; a read the controller forgot, after a profile change, starts again. + const missing = state === undefined + useEffect(() => { if (missing) load(name, version, language, fromNpm) }, [key, missing]) + const labels = useDetailDocumentLabels({ + title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'), + codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('detailFootnotes'), + }) + const detail = state?.status === 'ready' ? state.detail : undefined + const document = detail?.status === 'found' ? detail.path : undefined + const images = useMemo( + () => document === undefined ? undefined : { resolve: value => image(name, document, value, fromNpm) }, + [name, document, fromNpm, image], + ) + const shown = detailDocumentState(state?.status === 'error' + ? { status: 'error', message: t(FAILURE_KEYS[state.failure]) } + : state === undefined || state.status === 'loading' ? state : { status: 'ready', markdown: detail?.status === 'found' ? detail.markdown : undefined }) + return { retry(name, version, language, fromNpm) }} /> +} diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index efb3fb04f7..793a40a186 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -72,10 +72,12 @@ .toolbar { display: flex; - flex: none; + flex: 0 1 auto; + flex-wrap: wrap; align-items: center; justify-content: flex-end; - gap: 16px; + gap: 8px 16px; + min-width: 0; white-space: nowrap; } @@ -1542,3 +1544,412 @@ label.registryOption { flex-direction: column; gap: 24px; } + +/* The list toolbar's sentence about the last update check. */ +.updatesNote { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.updatesNote[data-tone='info'] { + color: var(--dsw-alias-state-business-primary); +} + +.updatesNote[data-tone='error'] { + color: var(--dsw-alias-state-error-primary); +} + +/* A bundle's Versions section: one summary row that unfolds into the newer versions or the registries asked. */ +.versionPanel { + container-type: inline-size; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-settings-card-stroke); + border-radius: var(--dsw-radius-xl); + background: var(--dsw-alias-settings-card-fill); +} + +.versionSummary { + display: flex; + align-items: center; + gap: 12px; + box-sizing: border-box; + width: 100%; + min-height: 64px; + padding: 12px 16px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +button.versionSummary { + cursor: pointer; +} + +button.versionSummary:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.versionSummary[aria-expanded='true'] { + background: var(--dsw-alias-bg-module-platform); +} + +button.versionSummary:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} + +.versionSummary[aria-expanded='true'] .detailsChevron, +.versionSummaryEnd [aria-expanded='true'] .detailsChevron { + transform: rotate(180deg); +} + +.versionGlyph { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-secondary); + corner-shape: round; +} + +.versionGlyph[data-tone='info'] { + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); + color: var(--dsw-alias-state-business-primary); +} + +.versionGlyph[data-tone='success'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.versionGlyph[data-tone='warn'] { + background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); + color: var(--dsw-alias-state-warn-primary); +} + +.versionGlyph[data-tone='error'] { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.versionSummaryText { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.versionSummaryTitle { + font-size: 14px; + line-height: 20px; + font-weight: 500; +} + +.versionSummarySub { + font-size: 12.5px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); + overflow-wrap: anywhere; +} + +.versionSummaryEnd { + display: inline-flex; + flex: none; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; +} + +.versionBody { + padding: 0 16px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.versionRows { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.versionRow { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content 56px; + align-items: center; + gap: 16px; + min-height: 28px; + padding: 12px 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.versionRow:last-child { + border-bottom: 0; +} + +.versionMain { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.versionTitle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.versionNumber { + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 20px; + font-weight: 500; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.versionRow[data-blocked] .versionNumber, +.versionNumber[data-old] { + color: var(--dsw-alias-label-tertiary); +} + +.versionNote { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.versionNote[data-tone='warn'] { + color: var(--dsw-alias-state-warn-label, var(--dsw-alias-state-warn-primary)); +} + +.versionDate { + flex: none; + font-size: 12.5px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); + font-variant-numeric: tabular-nums; +} + +.versionAction { + display: flex; + flex: none; + justify-content: flex-end; + min-width: 56px; +} + +.versionLog { + display: block; + margin-top: 4px; + font-family: var(--ds-font-family-code); + font-size: 11.5px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.versionMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 24px; + padding: 12px 0; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.versionFact { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 12px; + line-height: 18px; +} + +.versionFact dt { + color: var(--dsw-alias-label-tertiary); +} + +.versionFact dd { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--dsw-alias-label-secondary); + font-variant-numeric: tabular-nums; +} + +.versionHint { + display: inline-flex; + border-radius: 999px; + corner-shape: round; +} + +.versionHint:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; +} + +.versionSpacer { + flex: 1; +} + +/* The update dialog: the versions it moves between, then the Host's steps and the outcome. */ +.updateDialog { + width: min(460px, 100%); +} + +.updateBody { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.updateVersions { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 20px 16px; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-tertiary); +} + +.updateVersions .versionNumber { + font-size: 16px; + line-height: 24px; + color: var(--dsw-alias-label-primary); +} + +.updateVersions .versionNumber[data-old] { + color: var(--dsw-alias-label-tertiary); +} + +.updateText { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.updateOutcome { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.updateOutput { + margin: 0; + max-height: 200px; + overflow: auto; + padding: 10px 12px; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + font-family: var(--ds-font-family-code); + font-size: 11.5px; + line-height: 16px; + color: var(--dsw-alias-label-secondary); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.updateSteps { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding: 12px 16px; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + list-style: none; +} + +.updateStep { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + line-height: 20px; +} + +.updateStep > :last-child { + min-width: 0; + overflow-wrap: anywhere; +} + +.updateStepDot { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + color: var(--dsw-alias-label-secondary); + corner-shape: round; +} + +.updateStep[data-state='done'] .updateStepDot { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.updateStep[data-state='failed'] .updateStepDot { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.updateStep[data-state='unknown'] .updateStepDot { + border: 0.5px solid var(--dsw-alias-border-l3); +} + +@container (max-width: 460px) { + .versionRow { + grid-template-columns: minmax(0, 1fr) 56px; + gap: 4px 12px; + } + + .versionMain { + grid-column: 1; + grid-row: 1; + } + + .versionDate { + grid-column: 1; + grid-row: 2; + } + + .versionAction { + grid-column: 2; + grid-row: 1 / span 2; + } + + .versionMeta { + gap: 8px 16px; + } + + .versionMeta > label { + flex-basis: 100%; + } + + .versionSpacer { + display: none; + } +} diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 77be2faf9f..51ab144d83 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -25,13 +25,15 @@ import type { createNavigationStore } from './navigation-store.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts' import { - asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, + asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, updateCandidate, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, - type PluginManagerFace, type RegistryChoice, + type PluginManagerFace, type PluginManagerState, type RegistryChoice, type VersionCheck, } from './manager-store.ts' import { managementText, noticeText, packageText, registryText, restartText, rowText, type Translate } from './presentation.ts' import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts' import type { ConfigPageForm } from './slot-contract.ts' +import { UpdateDialog, VersionSection } from './versions.tsx' +import { BundleDetailSection } from './BundleDetailSection.tsx' import css from './PluginManagerPage.module.css' import badgeSkillPreview from './assets/badge-skill-preview.svg' import badgeSkillPreviewZh from './assets/badge-skill-preview.zh.svg' @@ -523,6 +525,21 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { ) } +/** The registry version a finished version check offers for a package, when it offers one. */ +function versionUpdate(check: VersionCheck | undefined): string | undefined { + return check?.phase === 'done' ? updateCandidate(check.list)?.version : undefined +} + +/** The list toolbar's sentence about the last update check of every updatable package. */ +function updatesNote(packages: readonly PackageView[], versions: PluginManagerState['versions'], t: Translate): { text: string; tone?: 'info' | 'error' } | undefined { + const checks = packages.filter(pkg => pkg.updatable).map(pkg => versions[pkg.name]) + if (checks.length === 0 || checks.some(check => check === undefined || check.phase === 'checking')) return undefined + const available = checks.filter(check => versionUpdate(check) !== undefined).length + if (available > 0) return { text: t('updatesAvailable', { count: String(available) }), tone: 'info' } + if (checks.some(check => check?.phase === 'done' && check.list.status === 'refused')) return { text: t('updatesFailed'), tone: 'error' } + return { text: t('updatesNone') } +} + function updateAvailable(pkg: PackageView): boolean { const target = pkg.installTarget // An update removes the installed package first, so a package that cannot be removed offers none. @@ -532,8 +549,10 @@ function updateAvailable(pkg: PackageView): boolean { } /** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ -function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnabled }: { +function PackageCard({ pkg, t, resolveText, busy, highlighted, newer, onOpen, onSetEnabled }: { readonly pkg: PackageView + /** The newer registry version a version check offers. */ + readonly newer: string | undefined readonly t: Translate readonly resolveText: ResolveText readonly busy: boolean @@ -560,7 +579,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab {beta ? {t('statusBeta')} : null} {status === 'problem' ? {t('statusProblem')} : null} {pkg.availability === 'missing' ? {t(pkg.installed ? 'statusMissingFiles' : 'statusNotInstalled')} : null} - {updateAvailable(pkg) ? {t('statusUpdateAvailable')} : null} + {updateAvailable(pkg) || newer !== undefined ? {t('statusUpdateAvailable')} : null} )} description={description} @@ -698,10 +717,14 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { * and configure controls; and where it comes from. */ function PackageDetail({ - pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore, - onBack, onSetEnabled, onUninstall, onUpdate, onSetRowEnabled, + pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore, versions, detail, + onBack, onSetEnabled, onUninstall, onUpdate, onSetRowEnabled, onEnsureVersions, onCheckVersions, onUpdateVersion, }: { readonly pkg: PackageView + /** The package's version check, when one started. */ + readonly versions: VersionCheck | undefined + /** The bundle's detail document section, shown first among the page's sections. */ + readonly detail: ReactNode readonly t: Translate readonly resolveText: ResolveText readonly busy: boolean @@ -717,8 +740,14 @@ function PackageDetail({ readonly onUninstall: () => void readonly onUpdate: () => void readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void + /** Check a package's versions unless a check already started. */ + readonly onEnsureVersions: (packageName: string) => void + readonly onCheckVersions: () => void + /** Ask before installing one listed newer version. */ + readonly onUpdateVersion: (version: string) => void }): ReactNode { const { title, description, beta } = packageText(pkg, resolveText) + const newer = versionUpdate(versions) const status = packageStatus(pkg) const previews = BUNDLE_PREVIEWS.get(pkg.name) const preview = previews === undefined ? undefined : resolveText(previews) @@ -739,6 +768,12 @@ function PackageDetail({ {t('update')} ) : null} + {newer === undefined ? null : ( + + )} {pkg.installed || pkg.removable ? ( + {state.install.requestId === undefined ? @@ -1730,11 +1773,20 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { configure={configure(openPkg)} renderSlot={renderSlot} inMore={openPkgInMore} + detail={( + + )} onBack={() => { setView({ kind: openPkgInMore ? 'more' : 'list' }) }} onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} onUninstall={() => { props.uninstall(openPkg.name) }} onUpdate={() => { props.update(openPkg.name) }} onSetRowEnabled={setRowEnabled} + versions={state.versions[openPkg.name]} + onEnsureVersions={props.ensureVersions} + onCheckVersions={() => { props.checkVersions(openPkg.name) }} + onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }} /> ) : null} @@ -1772,7 +1824,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} - {showsCards && activated !== undefined && !state.install.open + {/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */} + {installedCards && activated !== undefined && !state.install.open ? renderSlot('plugins.bundle.activation', { packageName: activated.name, onDismiss: () => { setActivation(null) }, @@ -1781,6 +1834,16 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} }) }, }, { entryKey: activated.name }) : null} + {state.update === null ? null : ( + + )} > + +/** + * The identity of a bundle detail read: the bundle, its installed version, and the display language. + * @param name - the bundle's package name. + * @param version - the installed or offered version, so an updated bundle reads again. + * @param language - the display language. + * @returns the key of the read in {@link BundleDetailsState}. + */ +export function bundleDetailKey(name: string, version: string | undefined, language: string): string { + return JSON.stringify([name, version ?? null, language]) +} + +/** The Host route of images beside bundle detail documents, relative to the application base. */ +const DETAIL_FILE_ROUTE = 'api/plugin-manager/detail-file' + +function failureOf(failure: RemoteFailure): BundleDetailFailure { + return failure.code === 'plugin-manager/detail-unavailable' ? failure.details.reason : 'other' +} + +/** Reads bundle detail documents and publishes them to the page. */ +export class BundleDetailController { + /** Detail reads published to the page. */ + readonly store: SnapshotStore = createSnapshotStore({}) + /** The latest read of each key, numbered across the controller's lifetime; an older read's answer is dropped. */ + private readonly reads = new Map() + private sequence = 0 + /** npm file CDNs to ask first, from the Host's latest registry comparison: npmmirror when its registry answered fastest. */ + private npmFileSources: 'npmmirror'[] = [] + private disposed = false + + /** + * @param ctx - the page plugin's context, whose `remote.pluginManager` namespace answers. + * @param base - the application base URL that Host routes resolve against. + */ + constructor(private readonly ctx: ClientContext, private readonly base: () => string) {} + + /** + * Read a bundle's document unless the read already ran or runs. + * @param name - the bundle's package name. + * @param version - its installed or offered version. + * @param language - the display language. + * @param fromNpm - whether the bundle's files are unavailable, so the Host reads its offered version from the npm file CDNs. + */ + load(name: string, version: string | undefined, language: string, fromNpm: boolean): void { + if (this.store.getSnapshot()[bundleDetailKey(name, version, language)] === undefined) void this.read(name, version, language, fromNpm) + } + + /** + * Read a bundle's document again. + * @param name - the bundle's package name. + * @param version - its installed or offered version. + * @param language - the display language. + * @param fromNpm - whether the Host reads the bundle from the npm file CDNs. + */ + retry(name: string, version: string | undefined, language: string, fromNpm: boolean): void { + void this.read(name, version, language, fromNpm) + } + + /** + * The URL of an image beside a bundle's document. + * @param name - the bundle's package name. + * @param document - the document's path inside the bundle. + * @param destination - the image destination as the document writes it. + * @param fromNpm - whether the Host reads the bundle from the npm file CDNs. + * @returns the authenticated Host URL, or undefined for an absolute, protocol-relative, or fragment destination. + */ + image(name: string, document: string, destination: string, fromNpm: boolean): string | undefined { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(destination)) return undefined + const sources = fromNpm ? this.npmFileSources : [] + const query = new URLSearchParams({ bundle: name, document, path: destination, ...sources.length > 0 ? { sources: sources.join(',') } : {} }) + return new URL(`${DETAIL_FILE_ROUTE}?${query.toString()}`, this.base()).href + } + + /** Forget every read, so each shown bundle reads its document again; bundle files can change at any profile change. */ + reset(): void { + this.reads.clear() + this.store.set({}) + } + + /** Stop publishing late settlements. */ + dispose(): void { + this.disposed = true + } + + private async read(name: string, version: string | undefined, language: string, fromNpm: boolean): Promise { + const key = bundleDetailKey(name, version, language) + const read = ++this.sequence + this.reads.set(key, read) + this.publish(key, { status: 'loading' }) + // The remote is captured before any wait: the plugin's context may be disposed while the comparison runs. + const { remote } = this.ctx + const current = (): boolean => !this.disposed && this.reads.get(key) === read + // Only a bundle read from the CDNs waits for the registry comparison. + if (fromNpm) { + const fastest = await remote.pluginRegistryProbe.fastest() + // A retry or reset started while the comparison ran settles the key instead. + if (!current()) return + this.npmFileSources = fastest.ok && fastest.value === NPMMIRROR_REGISTRY ? ['npmmirror'] : [] + } + const answer = await remote.pluginManager.readBundleDetail(name, language, { npmFileSources: fromNpm ? this.npmFileSources : [] }) + if (current()) this.publish(key, answer.ok ? { status: 'ready', detail: answer.value } : { status: 'error', failure: failureOf(answer.error) }) + } + + private publish(key: string, state: BundleDetailState): void { + this.store.set({ ...this.store.getSnapshot(), [key]: state }) + } +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 548b5fe8e5..870ccba12f 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -84,12 +84,15 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { // A page never rendered holds no snapshot to refresh. const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() if (controller.getSnapshot().status !== 'idle') void controller.load() } const disposers = [ ctx.remote.$on('plugin-manager/changed', refresh), ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }), ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }), + ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }), ctx.on('connection/reset', refresh), ] return () => { for (const dispose of disposers) dispose() } diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index b0cb0c1dfb..e22d8d1d07 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -122,6 +122,64 @@ export const zh = { sourceTargetVersion: '当前 DSH 对应版本', update: '更新', updateLabel: '更新 {name}', + versionsTitle: '版本', + versionsRecheck: '重新检查', + versionsChecking: '正在检查更新', + versionsAvailable: '可更新到 {version}', + versionsPublished: '发布于 {date}', + versionsLatest: '已是最新版本', + versionsCheckedAt: '上次检查于 {time}', + versionsBlocked: '新版本需要更新 DSH', + versionsBlockedDetail: '{version} 需要 {peers},当前 DSH 为 {runtime}', + versionsNoneInstallable: '没有可直接安装的新版本', + versionsNoneInstallableDetail: '新版本已弃用、为预发布版本或没有声明组合包,展开查看', + versionsFailedNetwork: '无法连接安装源', + versionsFailedOther: '无法检查更新', + versionsDetails: '详情', + versionsNewerCount: '{count} 个新版本', + versionsCollapse: '收起', + versionsInstalledTag: '已安装', + versionsPrereleaseTag: '预发布', + versionsDeprecatedTag: '已弃用', + versionsNeedsDsh: '需要 {peers}', + versionsNotBundle: '这个版本没有声明组合包,无法安装', + versionsUpdateLabel: '更新到 {version}', + versionsRegistry: '安装源', + versionsCheckedAtLabel: '检查于', + versionsFallbackTag: '备选源', + versionsFallbackHint: '{registry} 无法连接,版本列表来自备选源', + versionsPrimaryTag: '首选源', + versionsShowPrerelease: '显示预发布版本', + versionsOmitted: '另有 {count} 个比已安装版本新的版本未列出', + versionsProblemNetwork: '无法连接', + versionsProblemNotFound: '未找到', + versionsProblemUnknown: '失败', + updatesAvailable: '{count} 个插件可更新', + updatesNone: '所有插件均为最新版本', + updatesFailed: '部分插件检查更新失败', + updateTitle: '更新 {name}', + updateSource: '安装来源', + updateDescription: '将从 {from} 更新到 {to},期间插件可能短暂停止。安装失败时会保留或重新安装 {from}。', + updateRunning: '正在更新', + updateStepChecking: '检查 {version}', + updateStepRemoving: '停止并移除 {version}', + updateStepInstalling: '从 {registry} 安装 {version}', + updateStepRestoring: '重新安装 {version}', + updateDone: '已更新到 {version}', + updateDoneDetail: '新版本已经开始运行。', + updateRestart: '已安装 {version},重启后生效', + updateRefused: '无法更新到 {version}', + updateRemoveFailed: '无法移除 {version}', + updateRestored: '更新失败,已恢复到 {version}', + updateRestoredDetail: '{to} 安装失败,插件已重新安装 {from}。', + updateKept: '无法更新到 {version}', + updateKeptDetail: '{from} 未被移除,仍在使用。{reason}', + updateBroken: '更新失败,插件未安装', + updateBrokenDetail: '{to} 安装失败,重新安装 {from} 也未成功。请从“添加插件”重新安装 {name}。', + updateLost: '无法确认更新结果', + updateLostDetail: '与 DSH 的连接中断:{reason}。刷新插件列表可查看当前版本。', + updateOutput: '查看安装输出', + updateFinish: '完成', installFailedTitle: '插件安装失败', installGithubFailedTitle: '无法访问 GitHub', installGithubTimeoutTitle: '连接 GitHub 超时', @@ -228,6 +286,13 @@ export const zh = { reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。', reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。', reasonOperationError: 'Host 报告了一个错误', + detailTitle: '详细介绍', + detailLoading: '正在读取详细介绍', + detailTooLarge: '详细介绍超过大小限制,未加载', + detailNetwork: '无法连接到详细介绍的来源', + detailInvalid: '插件声明的详细介绍文件无效', + detailFailed: '无法读取详细介绍', + detailFootnotes: '脚注', } satisfies Record /** Plugin manager locale key union. */ @@ -350,6 +415,64 @@ export const en = { sourceTargetVersion: 'Version for this DSH', update: 'Update', updateLabel: 'Update {name}', + versionsTitle: 'Versions', + versionsRecheck: 'Check again', + versionsChecking: 'Checking for updates', + versionsAvailable: 'Update to {version} available', + versionsPublished: 'Published {date}', + versionsLatest: 'Up to date', + versionsCheckedAt: 'Last checked at {time}', + versionsBlocked: 'Newer versions need a DSH update', + versionsBlockedDetail: '{version} needs {peers}; this DSH is {runtime}', + versionsNoneInstallable: 'No newer version to install directly', + versionsNoneInstallableDetail: 'The newer versions are deprecated, prereleases, or declare no bundle. Expand to see them', + versionsFailedNetwork: 'Could not reach the registry', + versionsFailedOther: 'Could not check for updates', + versionsDetails: 'Details', + versionsNewerCount: '{count} newer', + versionsCollapse: 'Collapse', + versionsInstalledTag: 'Installed', + versionsPrereleaseTag: 'Prerelease', + versionsDeprecatedTag: 'Deprecated', + versionsNeedsDsh: 'Needs {peers}', + versionsNotBundle: 'This version declares no bundle and cannot be installed', + versionsUpdateLabel: 'Update to {version}', + versionsRegistry: 'Registry', + versionsCheckedAtLabel: 'Checked at', + versionsFallbackTag: 'Fallback', + versionsFallbackHint: '{registry} could not be reached; the list comes from a fallback registry', + versionsPrimaryTag: 'First', + versionsShowPrerelease: 'Show prereleases', + versionsOmitted: '{count} more versions newer than the installed one are not listed', + versionsProblemNetwork: 'Unreachable', + versionsProblemNotFound: 'Not found', + versionsProblemUnknown: 'Failed', + updatesAvailable: 'Updates available: {count}', + updatesNone: 'All plugins are up to date', + updatesFailed: 'Some plugins could not be checked', + updateTitle: 'Update {name}', + updateSource: 'Install from', + updateDescription: 'The update replaces {from} with {to}; the plugin may stop briefly. If the installation fails, {from} is kept or installed again.', + updateRunning: 'Updating', + updateStepChecking: 'Check {version}', + updateStepRemoving: 'Stop and remove {version}', + updateStepInstalling: 'Install {version} from {registry}', + updateStepRestoring: 'Install {version} again', + updateDone: 'Updated to {version}', + updateDoneDetail: 'The new version is running.', + updateRestart: '{version} installed; it takes effect after a restart', + updateRefused: 'Cannot update to {version}', + updateRemoveFailed: 'Could not remove {version}', + updateRestored: 'Update failed; restored {version}', + updateRestoredDetail: '{to} failed to install; {from} was installed again.', + updateKept: 'Could not update to {version}', + updateKeptDetail: '{from} was not removed and stays in use. {reason}', + updateBroken: 'Update failed; the plugin is not installed', + updateBrokenDetail: '{to} failed to install, and installing {from} again failed too. Install {name} again from Add plugin.', + updateLost: 'The update result is unknown', + updateLostDetail: 'The connection to DSH was interrupted: {reason}. Refresh the plugin list to see the installed version.', + updateOutput: 'Show installation output', + updateFinish: 'Done', installFailedTitle: 'The plugin could not be installed', installGithubFailedTitle: 'Cannot access GitHub', installGithubTimeoutTitle: 'GitHub connection timed out', @@ -456,4 +579,11 @@ export const en = { reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.', reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.', reasonOperationError: 'The Host reported an error.', + detailTitle: 'Details', + detailLoading: 'Reading details', + detailTooLarge: 'The details exceed the size limit and were not loaded.', + detailNetwork: 'Could not reach the source of the details.', + detailInvalid: 'The plugin declares an invalid details file.', + detailFailed: 'Could not read the details.', + detailFootnotes: 'Footnotes', } satisfies Record diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 49bb36b67d..775cbf0212 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -23,6 +23,11 @@ import type { PluginInstallRequestId, PluginRegistries, PluginSpecInspection, + PluginUpdateProgress, + PluginUpdateResult, + PluginUpdateStep, + PluginVersion, + PluginVersionList, ReadOnlyReason, Registry, RestartReason, @@ -36,6 +41,8 @@ import type { ConfigLedger } from './config-ledger.ts' import type { PluginTabInstall } from './slot-contract.ts' import type { PluginTab } from './tab-ledger.ts' import { shortName } from './presentation.ts' +import { BundleDetailController, type BundleDetailsState } from './detail-store.ts' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' /** The action a failed notice names. */ export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable' @@ -100,6 +107,8 @@ export interface PackageView { readonly optional: boolean /** Whether the Host removes the bundle: a profile dependency, or a selection that neither the profile nor the installation holds. */ readonly removable: boolean + /** Whether the Host lists and installs newer registry versions of the bundle. */ + readonly updatable: boolean /** Whether the bundle is in the profile's layer list. */ readonly enabled: boolean /** Why the Host refuses to switch the bundle off or remove it, when it does. */ @@ -300,6 +309,50 @@ export interface ConfirmState { readonly packageName: string } +/** One package's version check: in flight, or the Host's answer and when it arrived. */ +export type VersionCheck = + | { readonly phase: 'checking' } + | { readonly phase: 'done'; readonly list: PluginVersionList; readonly checkedAt: number } + +/** + * The version an update offers first: the newest one that declares a bundle, accepts the running DSH version, and is + * neither deprecated nor a prerelease. + * @param list - the Host's version list. + * @returns the version, or undefined when none qualifies or the list was refused. + */ +export function updateCandidate(list: PluginVersionList): PluginVersion | undefined { + if (list.status !== 'listed') return undefined + return list.versions.find(version => installable(version) && version.deprecated === undefined && !version.prerelease) +} + +/** + * Whether the Host can install a listed version in place of the installed one. + * @param version - one listed version. + * @returns true when it declares a bundle and accepts the running DSH version. + */ +export function installable(version: PluginVersion): boolean { + return version.bundle && version.incompatible === undefined +} + +/** One update from its confirmation to its outcome. */ +export interface UpdateState { + readonly packageName: string + readonly from: string + readonly to: string + /** The registry that listed the target; the Host checks and installs it there. */ + readonly registry: Registry + readonly phase: 'confirm' | 'running' | 'done' + /** The steps the Host announced, in order; the last one runs while the update does. */ + readonly steps: readonly PluginUpdateStep[] + readonly requestId?: PluginInstallRequestId + /** The Host's answer, once it settled. */ + readonly result: PluginUpdateResult | null + /** The transport's words when the answer was lost; the Host may still have finished the update. */ + readonly lost?: string + /** Whether the failed installation's pnpm output is unfolded. */ + readonly outputOpen: boolean +} + /** What the tab renders. */ export interface PluginManagerState { /** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */ @@ -318,6 +371,9 @@ export interface PluginManagerState { readonly confirm: ConfirmState | null /** The package the list scrolls to and marks, once an install enabled it. */ readonly highlight: string | null + /** Version checks by package name; a package never checked has no entry. */ + readonly versions: Readonly> + readonly update: UpdateState | null } /** The registration-side face the tab's slot entry injects. */ @@ -335,10 +391,20 @@ export interface PluginManagerFace { configLedger: HostObservable /** Tabs other plugins contribute, bound by the renderer as usePluginTabs. */ pluginTabs: HostObservable + /** Bundle detail reads, bound by the renderer as useBundleDetails. */ + bundleDetails: SnapshotStore + /** The active locale, bound as useLocale, for the language detail documents are read in. */ + locale: HostObservable } + /** Read a bundle's detail document unless the read already ran or runs. */ + loadDetail: BundleDetailController['load'] + /** Read a bundle's detail document again. */ + retryDetail: BundleDetailController['retry'] + /** The URL of an image beside a bundle's detail document, or undefined for a destination the page loads as written. */ + detailImage: BundleDetailController['image'] /** Read the Host once the tab first renders. */ ensure: () => void - /** Read the Host again. */ + /** Read the Host again, then check every updatable package for newer versions. */ refresh: () => void openInstall: () => void /** @@ -383,6 +449,18 @@ export interface PluginManagerFace { /** Switch one of a bundle's rows on or off in the profile's user layer. */ setRowEnabled: (entryId: PluginEntryId, enabled: boolean) => void dismissNotice: () => void + /** Ask the Host for an updatable package's newer versions, again when asked again. */ + checkVersions: (packageName: string) => void + /** Check a package's versions once: a package already checked or being checked is left as it is. */ + ensureVersions: (packageName: string) => void + /** Ask before replacing a package's installed version with a listed newer one. */ + openUpdate: (packageName: string, version: string) => void + /** Start the confirmed update. */ + runUpdate: () => void + /** Leave the confirmation or the outcome; a running update keeps its dialog. */ + closeUpdate: () => void + /** Fold or unfold a failed installation's pnpm output. */ + toggleUpdateOutput: () => void } /** A Remote answer as the generated client returns it. */ @@ -466,6 +544,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): installed: bundle.installed, optional: bundle.optional, removable: bundle.removable, + updatable: bundle.updatable, enabled: bundle.enabled, rows, ...bundle.version === undefined ? {} : { version: bundle.version }, @@ -564,6 +643,8 @@ export class PluginManagerController { private registryRead: RegistryRead | undefined /** A Discover entry chose the dialog's registry; that choice applies to this dialog only and is never remembered. */ private transientRegistry = false + /** Bundle detail documents the page shows. */ + readonly details: BundleDetailController /** The registry last used from this browser, kept across dialogs and page loads; null until one was used. */ private readonly registryMemory: SnapshotStore = createSnapshotStore(null, { persist: { name: 'dsh.plugin-manager.install-registry' }, @@ -575,9 +656,10 @@ export class PluginManagerController { constructor( private readonly ctx: ClientContext, ) { + this.details = new BundleDetailController(ctx, () => document.baseURI) this.store = createSnapshotStore({ status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null, - install: IDLE_INSTALL, confirm: null, highlight: null, + install: IDLE_INSTALL, confirm: null, highlight: null, versions: {}, update: null, }) } @@ -592,6 +674,7 @@ export class PluginManagerController { /** Stop publishing and drop every late settlement. */ dispose(): void { this.disposed = true + this.details.dispose() this.registryRead = undefined this.request = undefined this.generation += 1 @@ -607,9 +690,16 @@ export class PluginManagerController { inject( configLedger: HostObservable, pluginTabs: HostObservable, resolveText: PluginManagerFace['resolveText'], ): PluginManagerFace { + const details = this.details return { resolveText, - hooks: { pluginManager: this.store, configLedger, pluginTabs, configurations: this.ctx.configForms.describe() }, + hooks: { + pluginManager: this.store, configLedger, pluginTabs, configurations: this.ctx.configForms.describe(), + bundleDetails: details.store, locale: this.ctx.locale, + }, + loadDetail: (name, version, language, fromNpm) => { details.load(name, version, language, fromNpm) }, + retryDetail: (name, version, language, fromNpm) => { details.retry(name, version, language, fromNpm) }, + detailImage: (name, document, destination, fromNpm) => details.image(name, document, destination, fromNpm), configForm: id => this.ctx.configForms.get(id), ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, refresh: () => { void this.refresh() }, @@ -723,6 +813,23 @@ export class PluginManagerController { }) }, dismissNotice: () => { this.patch({ notice: null }) }, + checkVersions: (packageName) => { void this.checkVersions(packageName) }, + ensureVersions: (packageName) => { + if (this.getSnapshot().versions[packageName] === undefined) void this.checkVersions(packageName) + }, + openUpdate: (packageName, version) => { + const check = this.getSnapshot().versions[packageName] + if (this.getSnapshot().update?.phase === 'running' || check?.phase !== 'done' || check.list.status !== 'listed') return + this.patch({ update: { + packageName, from: check.list.installed, to: version, registry: check.list.registry, phase: 'confirm', steps: [], result: null, outputOpen: false, + } }) + }, + runUpdate: () => { void this.runUpdate() }, + closeUpdate: () => { if (this.getSnapshot().update?.phase !== 'running') this.patch({ update: null }) }, + toggleUpdateOutput: () => { + const update = this.getSnapshot().update + if (update !== null) this.patch({ update: { ...update, outputOpen: !update.outputOpen } }) + }, } } @@ -746,6 +853,65 @@ export class PluginManagerController { return started } + /** + * Follow the Host's steps of this tab's update. + * @param progress - a request id and step received from the Host. + */ + updateProgress(progress: PluginUpdateProgress): void { + const update = this.getSnapshot().update + if (update?.phase !== 'running' || update.requestId !== progress.requestId || update.steps.includes(progress.step)) return + this.patch({ update: { ...update, steps: [...update.steps, progress.step] } }) + } + + /** Read one package's newer versions; a transport failure reads as an unknown refusal. */ + private async checkVersions(packageName: string): Promise { + const { versions, update } = this.getSnapshot() + // A check during the package's update would answer for the version being replaced. + if (this.disposed || versions[packageName]?.phase === 'checking' || (update?.phase === 'running' && update.packageName === packageName)) return + this.patchVersions(packageName, { phase: 'checking' }) + // The generated Remote takes every business argument before its optional signal; `{}` asks the Host's own plan. + const answer = await this.ctx.remote.pluginManager.listVersions(packageName, {}) + const list: PluginVersionList = answer.ok ? answer.value : { status: 'refused', problem: 'unknown', reason: answer.error.message, attempts: [] } + this.patchVersions(packageName, { phase: 'done', list, checkedAt: Date.now() }) + } + + /** Check the updatable packages one after another, so one click starts at most one package's registry lookups. */ + private async checkAllVersions(): Promise { + for (const pkg of this.getSnapshot().packages.filter(item => item.updatable)) await this.checkVersions(pkg.name) + } + + /** Hand the confirmed update to the Host, keep its outcome, then read the package and its versions again. */ + private async runUpdate(): Promise { + const update = this.getSnapshot().update + if (this.disposed || update === null || update.phase !== 'confirm') return + const requestId = randomUUID() as PluginInstallRequestId + const running: UpdateState = { ...update, phase: 'running', steps: [], requestId, result: null } + this.patch({ update: running }) + const answer = await this.ctx.remote.pluginManager.updateBundle(update.packageName, update.to, { registry: update.registry, requestId }) + // Nothing closes or replaces a running update before its answer; after disposal `patch` publishes nothing. + const current = this.getSnapshot().update as UpdateState + this.patch({ update: { ...current, phase: 'done', ...answer.ok ? { result: answer.value } : { lost: answer.error.message } } }) + await this.load() + await this.checkVersions(update.packageName) + } + + /** + * Forget the checks that no longer describe a listed package: one that left the list or stopped being updatable, and + * one answered for another installed version, after a reinstallation or a change from another surface. + */ + private dropStaleVersions(): void { + const { packages, versions } = this.getSnapshot() + const current = Object.fromEntries(Object.entries(versions).filter(([name, check]) => { + const pkg = packages.find(item => item.name === name) + return pkg?.updatable === true && (check.phase === 'checking' || check.list.status !== 'listed' || check.list.installed === pkg.version) + })) + if (Object.keys(current).length !== Object.keys(versions).length) this.patch({ versions: current }) + } + + private patchVersions(packageName: string, check: VersionCheck): void { + this.patch({ versions: { ...this.getSnapshot().versions, [packageName]: check } }) + } + /** * Follow the Host's cancellation window for this dialog's installation. * @param progress - a request id and phase received from the Host. @@ -832,7 +998,10 @@ export class PluginManagerController { return run } - /** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */ + /** + * Keep manual refresh feedback until its coalesced reads and the version checks of the updatable packages settle, + * without clearing cached cards; a failed read checks nothing. + */ private async refresh(): Promise { if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return const startedAt = Date.now() @@ -842,6 +1011,7 @@ export class PluginManagerController { }) try { await this.load() + if (this.getSnapshot().status !== 'error') await this.checkAllVersions() } catch (_error) { // A rejected transport request leaves the cached cards available for retry. this.patch({ status: 'error' }) @@ -896,6 +1066,7 @@ export class PluginManagerController { refreshStatus: this.getSnapshot().refreshStatus === 'refreshing' ? 'refreshing' : 'idle', packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))), }) + this.dropStaleVersions() } while (this.shouldRerun()) } finally { this.inFlight = undefined diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index ff37603568..b7a2c3fea2 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -160,7 +160,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'plugins.detail.badge': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } /** - * Sections under a detail page's own content: after the rows on a bundle's + * Sections under a detail page's own content: after the source on a bundle's * page, after the configuration on a row's or an official plugin's page. * An entry draws its own section chrome and renders null for a subject it * has nothing for. diff --git a/packages/client/ui-plugin-manager/src/client/versions.tsx b/packages/client/ui-plugin-manager/src/client/versions.tsx new file mode 100644 index 0000000000..dfb352a890 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/versions.tsx @@ -0,0 +1,456 @@ +/** + * A registry bundle's Versions section and its update dialog. The section summarizes the Host's version check in one + * row and unfolds into the newer versions; the dialog confirms one update, follows the Host's steps, and reports how + * the update ended, including the reinstallation of the removed version after a failed installation. + */ + +import { useEffect, useState, type ReactNode } from 'react' +import type { + IncompatiblePlugin, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry, RegistryAttempt, +} from '@deepseek-ai/dsh-api-remotes/client' +import { + Button, Checkbox, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, + IconDownloadOutlineRegular, IconRefreshOutlineRegular, IconWarningOutlineRegular, Modal, StateDot, Tag, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { installable, updateCandidate, type PackageView, type UpdateState, type VersionCheck } from './manager-store.ts' +import { managementText, registryText, restartText, type Translate } from './presentation.ts' +import css from './PluginManagerPage.module.css' + +/** The tone of a summary or outcome glyph. */ +type Tone = 'info' | 'success' | 'warn' | 'error' | 'neutral' + +/** Two digits of a date or time field. */ +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +/** A version's local publication day as `YYYY-MM-DD`, which reads the same in every UI language. */ +function publishedDate(version: PluginVersion): string | undefined { + if (version.publishedAt === undefined) return undefined + const day = new Date(version.publishedAt) + return `${String(day.getFullYear())}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}` +} + +/** The local time of day a check arrived, as 24-hour `HH:MM`. */ +function checkedTime(checkedAt: number): string { + const time = new Date(checkedAt) + return `${pad(time.getHours())}:${pad(time.getMinutes())}` +} + +/** The registry by name: npm's own and the mainland mirror by theirs, pnpm's own configuration as the default, any other by host. */ +function registryName(registry: Registry, t: Translate): string { + return registryText(registry, t, null).name +} + +/** The DSH requirements an incompatible version states, as one phrase: `engines.dsh` or DSH peer ranges. */ +function peersText(incompatible: IncompatiblePlugin): string { + return Object.entries(incompatible.requirements).map(([name, range]) => `${name} ${range}`).join(', ') +} + +const PROBLEM_KEYS = { + 'network': 'versionsProblemNetwork', + 'not-found': 'versionsProblemNotFound', + 'unknown': 'versionsProblemUnknown', +} as const + +function Glyph({ tone, children }: { readonly tone: Tone; readonly children: ReactNode }): ReactNode { + return +} + +/** The summary row's glyph, title, the line under it, and how many newer versions it unfolds into. */ +interface Summary { + tone: Tone + glyph: ReactNode + title: string + sub?: string + newer: number +} + +function summaryOf(check: VersionCheck | undefined, t: Translate): Summary { + if (check === undefined || check.phase === 'checking') { + return { tone: 'neutral', glyph: , title: t('versionsChecking'), newer: 0 } + } + const { list } = check + if (list.status === 'refused') { + const failed = list.attempts.at(-1) + return { + tone: 'error', glyph: , + title: t(list.problem === 'network' ? 'versionsFailedNetwork' : 'versionsFailedOther'), + // A check the transport lost asked no registry the page knows of; its words stand alone. + sub: failed?.problem === undefined ? list.reason : `${registryName(failed.registry, t)} ${t(PROBLEM_KEYS[failed.problem])}`, + newer: 0, + } + } + const candidate = updateCandidate(list) + if (candidate !== undefined) { + const date = publishedDate(candidate) + return { + tone: 'info', glyph: , title: t('versionsAvailable', { version: candidate.version }), + ...date === undefined ? {} : { sub: t('versionsPublished', { date }) }, newer: list.versions.length, + } + } + if (list.versions.length === 0) { + return { tone: 'success', glyph: , title: t('versionsLatest'), sub: t('versionsCheckedAt', { time: checkedTime(check.checkedAt) }), newer: 0 } + } + const blocked = list.versions.find(version => !version.prerelease && version.incompatible !== undefined)?.incompatible + return blocked === undefined + ? { tone: 'warn', glyph: , title: t('versionsNoneInstallable'), sub: t('versionsNoneInstallableDetail'), newer: list.versions.length } + : { + tone: 'warn', glyph: , title: t('versionsBlocked'), newer: list.versions.length, + sub: t('versionsBlockedDetail', { version: blocked.version, peers: peersText(blocked), runtime: blocked.runtimeVersion }), + } +} + +/** One version row: its number and tags, why it cannot or should not be installed, its date, and its update action. */ +function VersionRow({ version, installed, t, busy, onUpdate }: { + readonly version: PluginVersion | { readonly version: string; readonly installedRow: true } + readonly installed: string + readonly t: Translate + readonly busy: boolean + readonly onUpdate: (version: string) => void +}): ReactNode { + if ('installedRow' in version) { + return ( +
  • +
    +
    + {version.version} + {t('versionsInstalledTag')} +
    +
    +
  • + ) + } + const usable = installable(version) + const note = version.incompatible !== undefined ? t('versionsNeedsDsh', { peers: peersText(version.incompatible) }) + : !version.bundle ? t('versionsNotBundle') : version.deprecated + const date = publishedDate(version) + return ( +
  • +
    +
    + {version.version} + {version.tags.map(tag => {tag})} + {version.prerelease ? {t('versionsPrereleaseTag')} : null} + {version.deprecated === undefined ? null : {t('versionsDeprecatedTag')}} +
    + {note === undefined ? null : {note}} +
    + {date === undefined ? null : {date}} + + {usable && version.version !== installed ? ( + + ) : null} + +
  • + ) +} + +/** The registries a refused check asked, each with what it answered. */ +function AttemptRows({ attempts, t }: { readonly attempts: readonly RegistryAttempt[]; readonly t: Translate }): ReactNode { + return ( +
      + {attempts.map((attempt, index) => ( +
    • +
      +
      + {registryName(attempt.registry, t)} + {t(index === 0 ? 'versionsPrimaryTag' : 'versionsFallbackTag')} + {attempt.problem === undefined ? null : {t(PROBLEM_KEYS[attempt.problem])}} +
      + {attempt.reason === undefined ? null : {attempt.reason}} +
      +
    • + ))} +
    + ) +} + +/** The unfolded list: newer versions, the installed one, and where and when the list was read. */ +function VersionList({ list, checkedAt, t, busy, onUpdate }: { + readonly list: Extract + readonly checkedAt: number + readonly t: Translate + readonly busy: boolean + readonly onUpdate: (version: string) => void +}): ReactNode { + const [prereleases, setPrereleases] = useState(false) + const shown = list.versions.filter(version => prereleases || !version.prerelease) + const fallback = list.attempts.length > 1 ? list.attempts[0] : undefined + return ( +
    +
      + {shown.map(version => ( + + ))} + +
    + {list.omitted > 0 ?

    {t('versionsOmitted', { count: String(list.omitted) })}

    : null} +
    +
    +
    {t('versionsRegistry')}
    +
    + {registryName(list.registry, t)} + {fallback === undefined ? null : ( + + {t('versionsFallbackTag')} + + )} +
    +
    +
    +
    {t('versionsCheckedAtLabel')}
    +
    {checkedTime(checkedAt)}
    +
    + + {list.versions.some(version => version.prerelease) + ? + : null} +
    +
    + ) +} + +/** + * The Versions section of an updatable bundle's page. It checks once when the page opens and folds the newer versions + * behind a summary row. + */ +export function VersionSection({ pkg, check, t, busy, onEnsure, onCheck, onUpdate }: { + readonly pkg: PackageView + readonly check: VersionCheck | undefined + readonly t: Translate + readonly busy: boolean + /** Check a package's versions unless a check already started. */ + readonly onEnsure: (packageName: string) => void + readonly onCheck: () => void + readonly onUpdate: (version: string) => void +}): ReactNode { + const [open, setOpen] = useState(false) + const { name, updatable } = pkg + useEffect(() => { if (updatable) onEnsure(name) }, [name, updatable, onEnsure]) + if (!updatable) return null + const done = check?.phase === 'done' ? check : undefined + const summary = summaryOf(check, t) + const refused = done?.list.status === 'refused' ? done.list : undefined + const listed = done?.list.status === 'listed' ? done.list : undefined + const unfolds = summary.newer > 0 || (refused !== undefined && refused.attempts.length > 0) + const expanded = open && unfolds + const text = ( + + {summary.title} + {summary.sub === undefined ? null : {summary.sub}} + + ) + return ( +
    +
    +

    {t('versionsTitle')}

    + + {refused === undefined ? ( + + ) : null} +
    +
    + {listed !== undefined && summary.newer > 0 ? ( + + ) : ( +
    + {summary.glyph} + {text} + {refused === undefined ? null : ( + + {unfolds ? ( + + ) : null} + + + )} +
    + )} + {expanded && listed !== undefined && done !== undefined + ? + : null} + {expanded && refused !== undefined + ?
    + : null} +
    +
    + ) +} + +/** The step labels of an update; the Host announces each step as it starts. */ +function stepLabel(step: PluginUpdateStep, update: UpdateState, t: Translate): string { + switch (step) { + case 'checking': return t('updateStepChecking', { version: update.to }) + case 'removing': return t('updateStepRemoving', { version: update.from }) + case 'installing': return t('updateStepInstalling', { version: update.to, registry: registryName(update.registry, t) }) + case 'restoring': return t('updateStepRestoring', { version: update.from }) + } +} + +/** Whether a change result left its operation undone. */ +function failed(application: PluginUpdateResult['result']['application']): boolean { + return application === 'failed' || application === 'cancelled' +} + +/** The steps in the order the Host runs them. */ +const STEP_ORDER: readonly PluginUpdateStep[] = ['checking', 'removing', 'installing', 'restoring'] + +/** The steps an answer implies, joined with the announced ones, so a lost announcement does not drop a step that ran. */ +function settledSteps(announced: readonly PluginUpdateStep[], result: PluginUpdateResult): PluginUpdateStep[] { + const implied = new Set(['checking', ...announced]) + if (result.step === 'remove') implied.add('removing') + if (result.step === 'install') implied.add('installing') + if (result.restore !== undefined) implied.add('restoring') + return STEP_ORDER.filter(step => implied.has(step)) +} + +/** How one step ended, or that it still runs. */ +function stepState(step: PluginUpdateStep, update: UpdateState, last: boolean): 'running' | 'done' | 'failed' | 'unknown' { + const result = update.result + if (update.phase === 'running') return last ? 'running' : 'done' + if (result === null) return last ? 'unknown' : 'done' + switch (step) { + case 'checking': return result.step === 'check' ? 'failed' : 'done' + case 'removing': return result.step === 'remove' ? 'failed' : 'done' + case 'installing': return failed(result.result.application) ? 'failed' : 'done' + case 'restoring': return result.restore !== undefined && failed(result.restore.application) ? 'failed' : 'done' + } +} + +const STEP_GLYPHS = { + running: , + done: , + failed: , + unknown: null, +} as const + +/** What the update's outcome reads as: its glyph, title, and the sentence under it. */ +function outcomeOf(update: UpdateState, title: string, t: Translate): Omit & { detail: string } { + const error = { tone: 'error' as const, glyph: } + const result = update.result + // A finished update without the Host's answer carries the transport's words. + if (result === null) return { ...error, title: t('updateLost'), detail: t('updateLostDetail', { reason: String(update.lost) }) } + // A failed step always carries the Host's refusal; an absent one reads as the generic operation error. + const reason = (change: PluginUpdateResult['result']): string => managementText({ code: 'operation-error', ...change.error, installing: true }, t) + if (result.step === 'check') return { ...error, title: t('updateRefused', { version: update.to }), detail: reason(result.result) } + if (result.step === 'remove') return { ...error, title: t('updateRemoveFailed', { version: update.from }), detail: reason(result.result) } + if (!failed(result.result.application)) { + return result.result.application === 'restart-required' + ? { tone: 'warn', glyph: , title: t('updateRestart', { version: update.to }), detail: restartText('restartNotice', result.result.restartReason, t) } + : { tone: 'success', glyph: , title: t('updateDone', { version: update.to }), detail: t('updateDoneDetail') } + } + // Without a reinstallation nothing was removed: the installation went over the running version and was rolled back. + if (result.restore === undefined) { + return { ...error, title: t('updateKept', { version: update.to }), detail: t('updateKeptDetail', { from: update.from, reason: reason(result.result) }) } + } + return failed(result.restore.application) + ? { ...error, title: t('updateBroken'), detail: t('updateBrokenDetail', { from: update.from, to: update.to, name: title }) } + : { ...error, title: t('updateRestored', { version: update.from }), detail: t('updateRestoredDetail', { from: update.from, to: update.to }) } +} + +/** The update dialog: confirmation, then the Host's steps, then the outcome. */ +export function UpdateDialog({ update, title, t, onRun, onClose, onToggleOutput }: { + readonly update: UpdateState + /** The package's display title. */ + readonly title: string + readonly t: Translate + readonly onRun: () => void + readonly onClose: () => void + readonly onToggleOutput: () => void +}): ReactNode { + const running = update.phase === 'running' + const steps = update.result === null ? update.steps : settledSteps(update.steps, update.result) + const outcome = update.phase === 'done' ? outcomeOf(update, title, t) : undefined + // The failed run's own output: the target's installation, else the removal or the reinstallation. + const output = [update.result?.result, update.result?.restore] + .map(change => change !== undefined && failed(change.application) ? change.packageResult?.output : undefined) + .find(text => text !== undefined && text !== '') + return ( + { if (!running) onClose() }} + title={t('updateTitle', { name: title })} + closeLabel={t('close')} + className={css.updateDialog as string} + footer={update.phase === 'confirm' ? ( + <> + + + + ) : running + ? + : } + > +
    + {update.phase === 'confirm' ? ( + <> +
    + {update.from} +
    +
    +
    {t('updateSource')}
    +
    {registryName(update.registry, t)}
    +
    +

    {t('updateDescription', { from: update.from, to: update.to })}

    + + ) : ( + <> + {outcome === undefined ? null : ( +
    + {outcome.glyph} + + {outcome.title} + {outcome.detail} + +
    + )} + {output === undefined ? null : ( + <> + + {update.outputOpen ?
    {output}
    : null} + + )} +
      + {steps.map((step, index) => { + const state = stepState(step, update, index === steps.length - 1) + return ( +
    1. + + {stepLabel(step, update, t)} +
    2. + ) + })} +
    + + )} +
    +
    + ) +} diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 3a5e468c68..6653080225 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -41,6 +41,7 @@ async function bench() { listBundles: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })), listPlugins: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })), registries: vi.fn(() => Promise.resolve({ ok: true as const, value: { registry: null, fallbackRegistries: [], resolved: null } })), + readBundleDetail: vi.fn(() => Promise.resolve({ ok: true as const, value: { status: 'none' as const } })), }, }) const panelInfo = createSnapshotStore({ activePanelId: null }) @@ -186,8 +187,15 @@ describe('ui-plugin-manager browser plugin', () => { face.ensure() await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) expect(b.list).toHaveBeenCalledTimes(1) + // Bundle detail reads go to the Host by name; a profile change forgets them. + face.loadDetail('dsh-a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(face.hooks.bundleDetails.getSnapshot()).not.toEqual({}) }) + face.retryDetail('dsh-a', '1.0.0', 'en', false) + expect(face.detailImage('dsh-a', 'README.md', 'a.png', false)).toContain('api/plugin-manager/detail-file?bundle=dsh-a') + expect(face.hooks.locale.getSnapshot().active).toBe('zh') b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }]) await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + expect(face.hooks.bundleDetails.getSnapshot()).toEqual({}) b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) }) @@ -197,6 +205,9 @@ describe('ui-plugin-manager browser plugin', () => { b.remote.emit('plugin-manager/install-log', [{ jobId: 'j', argv: ['pnpm', 'add', 'pkg'], cwd: '/p', stream: 'stdout', text: 'early' }]) b.remote.emit('plugin-manager/install-state', [{ requestId: 'foreign', phase: 'installing' }]) expect(face.hooks.pluginManager.getSnapshot().install.runs).toEqual([]) + // Update progress reaches only the update this page started. + b.remote.emit('plugin-manager/update-state', [{ requestId: 'foreign', step: 'checking' }]) + expect(face.hooks.pluginManager.getSnapshot().update).toBeNull() await fiber.dispose() expect(b.ctx.get('pluginNavigation')).toBeUndefined() diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index be382f110b..6c2d35293a 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -4,19 +4,21 @@ import { act, cleanup, fireEvent, render, screen, within } from '@testing-librar import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' +import type { ChangeResult, PluginEntryId, PluginInstallRequestId, PluginUpdateResult, PluginVersion, RegistryAttempt } from '@deepseek-ai/dsh-api-remotes/client' import { allowAllAccess, bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { StrictMode, type ReactNode } from 'react' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { bundleDetailKey, type BundleDetailsState } from '../src/client/detail-store.ts' import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx' import type { PluginManagerPageProps } from '../src/client/index.ts' import type { PluginTab } from '../src/client/tab-ledger.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' -import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' +import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState, type UpdateState, type VersionCheck } from '../src/client/manager-store.ts' import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' import type { PluginActivationOwnerProps, PluginAddActionsProps, PluginDetailProps, PluginTabProps, PluginsSubject } from '../src/client/slot-contract.ts' @@ -40,6 +42,7 @@ function pkg(overrides: Partial = {}): PackageView { installed: true, optional: false, removable: true, + updatable: false, enabled: true, rows: [], ...overrides, @@ -78,6 +81,8 @@ const READY: PluginManagerState = { busy: [], notice: null, install: IDLE_INSTALL, + versions: {}, + update: null, confirm: null, highlight: null, } @@ -108,6 +113,7 @@ function renderTab( const store = createSnapshotStore({ ...READY, ...state }) const ledger = createSnapshotStore({ ...NO_CONFIG, ...config }) const tabLedger = createSnapshotStore(tabs) + const bundleDetails = createSnapshotStore({}) const actions = { ensure: vi.fn(), refresh: vi.fn(), @@ -133,6 +139,16 @@ function renderTab( cancelConfirm: vi.fn(), setRowEnabled: vi.fn(), dismissNotice: vi.fn(), + checkVersions: vi.fn(), + ensureVersions: vi.fn(), + openUpdate: vi.fn(), + runUpdate: vi.fn(), + closeUpdate: vi.fn(), + toggleUpdateOutput: vi.fn(), + loadDetail: vi.fn(), + retryDetail: vi.fn(), + detailImage: vi.fn((name: string, document: string, destination: string, _fromNpm: boolean) => + `https://dsh.example/api/plugin-manager/detail-file?bundle=${name}&document=${document}&path=${destination}`), } const unusedStandardHook = (): never => { throw new Error('Plugin manager fixture does not provide global state') } const standard = { @@ -154,6 +170,8 @@ function renderTab( usePluginManager: bindSnapshotSelector(store), useConfigLedger: bindSnapshotSelector(ledger), usePluginTabs: bindSnapshotSelector(tabLedger), + useBundleDetails: bindSnapshotSelector(bundleDetails), + useLocale: bindSnapshotSelector(createSnapshotStore({ active: 'en', locales: [], revision: 0 })), useConfigurations: bindSnapshotSelector(createSnapshotStore({ status: 'ready', error: null, view: { writable: true, hasDocument: true, namespaces: Object.keys(forms).map(ns => ({ @@ -197,6 +215,7 @@ function renderTab( store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, + setDetails: (next: BundleDetailsState) => { act(() => { bundleDetails.set(next) }) }, setLanguage: (dict: typeof en) => { locale.setLocale(dict === zh ? 'zh' : 'en') currentT = translate(dict) @@ -207,7 +226,7 @@ function renderTab( describe('PluginManagerPage', () => { it('shows no tabs without contributions, and switches between Installed and a contributed tab', () => { - const { actions, navigation, setTabs, set } = renderTab({ packages: [pkg()] }, {}, { + const { actions, navigation, setTabs } = renderTab({ packages: [pkg()] }, {}, { 'plugins.tab:discover': (_view, owner) => { const tab = owner as PluginTabProps return ( @@ -238,13 +257,20 @@ describe('PluginManagerPage', () => { setTabs([]) expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull() expect(navigation.getSnapshot().tab).toBe('installed') - // Enabling a bundle an install added highlights its card, so the page shows Installed. + // A contributed tab that returns can be selected again. setTabs([{ id: 'discover', label: 'Discover' }]) fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() + }) + + it('marks the bundle an install enabled without leaving a contributed tab', () => { + const { navigation, set } = renderTab({ packages: [pkg()] }, {}, {}, {}, [{ id: 'discover', label: 'Discover' }]) + fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) + expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() + // Enable now publishes the new bundle's mark; the mark scrolls to a visible card and selects no page. set({ highlight: 'dsh-better-sidebar' }) - expect(navigation.getSnapshot().tab).toBe('installed') - expect(screen.getByRole('tabpanel', { name: en.installedTab })).toBeTruthy() + expect(navigation.getSnapshot().tab).toBe('discover') + expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() }) it('keeps the Installed tab panel through loading and the More list out of the tabs', () => { @@ -418,12 +444,40 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull() }) + it('shows a bundle\'s detail document first among its sections, and its failures with a retry', () => { + const b = renderTab({ packages: [pkg()] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + expect(b.actions.loadDetail).toHaveBeenCalledWith('dsh-better-sidebar', '0.16.0', 'en', false) + expect(screen.getByRole('status', { name: en.detailLoading })).toBeTruthy() + const key = bundleDetailKey('dsh-better-sidebar', '0.16.0', 'en') + b.setDetails({ [key]: { status: 'ready', detail: { status: 'found', origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Sidebar\n\n![shot](img/a.png)' } } }) + const sections = [...document.querySelectorAll('[data-plugin-detail="dsh-better-sidebar"] section')] + const order = sections.map(section => section.hasAttribute('data-detail-document') ? 'detail' : section.hasAttribute('data-plugin-source') ? 'source' : 'other') + expect(order[0]).toBe('detail') + expect(order).toContain('source') + expect(screen.getByRole('heading', { name: 'Sidebar' })).toBeTruthy() + expect(screen.getByRole('img', { name: 'shot' }).getAttribute('src')) + .toBe('https://dsh.example/api/plugin-manager/detail-file?bundle=dsh-better-sidebar&document=guide/DETAIL.md&path=img/a.png') + const failures = [['too-large', en.detailTooLarge], ['network', en.detailNetwork], ['denied', en.detailFailed], ['invalid', en.detailInvalid], ['other', en.detailFailed]] as const + for (const [failure, text] of failures) { + b.setDetails({ [key]: { status: 'error', failure } }) + expect(screen.getByRole('alert').textContent).toBe(text) + } + fireEvent.click(screen.getByRole('button', { name: en.retry })) + expect(b.actions.retryDetail).toHaveBeenCalledWith('dsh-better-sidebar', '0.16.0', 'en', false) + b.setDetails({ [key]: { status: 'ready', detail: { status: 'none' } } }) + expect(document.querySelector('[data-detail-document]')).toBeNull() + // A read the controller forgot, after a profile change, starts again. + b.setDetails({}) + expect(b.actions.loadDetail).toHaveBeenCalledTimes(2) + }) + it('says where a bundle comes from: the spec that installs it, or built in, with its version', () => { const b = renderTab({ packages: [ pkg({ source: 'github:someone/dsh-better-sidebar' }), - { name: 'dsh-official', installed: false, official: true, availability: 'installation', optional: true, removable: false, enabled: false, rows: [] }, - { name: 'dsh-shadowed', installed: true, official: false, availability: 'profile', optional: false, removable: false, enabled: true, rows: [] }, - { name: 'dsh-missing', installed: false, official: false, availability: 'missing', optional: false, removable: true, enabled: true, error: { code: 'unknown-plugin' }, rows: [] }, + { name: 'dsh-official', installed: false, official: true, availability: 'installation', optional: true, removable: false, updatable: false, enabled: false, rows: [] }, + { name: 'dsh-shadowed', installed: true, official: false, availability: 'profile', optional: false, removable: false, updatable: false, enabled: true, rows: [] }, + { name: 'dsh-missing', installed: false, official: false, availability: 'missing', optional: false, removable: true, updatable: false, enabled: true, error: { code: 'unknown-plugin' }, rows: [] }, ] }) const facts = (): string[] => [...document.querySelectorAll('[data-plugin-source] dt, [data-plugin-source] dd')].map(node => node.textContent) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) @@ -436,6 +490,8 @@ describe('PluginManagerPage', () => { expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-missing' }) }) expect(facts()).toEqual([en.sourceSpec, en.sourceNotInstalled]) + // A bundle whose files are not installed is read from the npm file CDNs. + expect(b.actions.loadDetail).toHaveBeenLastCalledWith('dsh-missing', undefined, 'en', true) }) it('preserves the requested bundle through StrictMode effect replay and page remounts', () => { @@ -796,12 +852,12 @@ describe('PluginManagerPage', () => { expect(chinese.getAttribute('src')).toMatch(/badge-skill-preview\.zh\.svg$/) fireEvent.error(chinese) expect(screen.queryByRole('img')).toBeNull() - expect(screen.getByRole('status').textContent).toBe(zh.previewUnavailable) + expect(screen.getByText(zh.previewUnavailable).getAttribute('role')).toBe('status') fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', name) })) expect(actions.setEnabled).toHaveBeenCalledWith(name, true) setLanguage(en) expect(screen.getByRole('img').getAttribute('src')).toBe(english) - expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByText(en.previewUnavailable)).toBeNull() }) it('shows no preview for bundles without one', () => { @@ -1250,7 +1306,7 @@ describe('PluginManagerPage', () => { }) it('leaves the version out of a bundle the Host reports none for', () => { - const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', optional: false, removable: true, enabled: true, rows: [] } + const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: false, enabled: true, rows: [] } renderTab({ packages: [unversioned] }, {}, bodies) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', enabled: true, rows: [] } }) @@ -2309,6 +2365,24 @@ it('dismisses activation guidance until the user enables the bundle again', () = expect(screen.getByText('Later')).toBeTruthy() }) +it('keeps activation guidance with the Installed list when a contributed tab enables the bundle', () => { + const name = 'dsh-better-sidebar' + const subject = { spec: `${name}@0.16.0`, status: 'accepted', kind: 'registry', name, bundle: true, registry: null } as const + const { set } = renderTab({ + install: { ...IDLE_INSTALL, open: true, phase: 'done', subject, installed: name }, + }, { bundles: new Set([name]) }, { + [`plugins.bundle.activation:${name}`]: () =>
    Enable guidance
    , + }, {}, [{ id: 'discover', label: 'Discover' }]) + fireEvent.click(screen.getByRole('button', { name: en.installEnableNow })) + set({ install: IDLE_INSTALL, packages: [pkg()] }) + expect(screen.getByText('Enable guidance')).toBeTruthy() + // A contributed tab shows its own content, not guidance for the Installed list. + fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) + expect(screen.queryByText('Enable guidance')).toBeNull() + fireEvent.click(screen.getByRole('tab', { name: en.installedTab })) + expect(screen.getByText('Enable guidance')).toBeTruthy() +}) + it('supplies the accepted entry values and atomic mutation action to a custom plugin page', () => { const mutate = vi.fn(async () => true) const form: ConfigPageForm = { @@ -2328,7 +2402,7 @@ describe('on-demand Official bundles', () => { const name = '@deepseek-ai/dsh-subagent-codex' const title = 'Codex subagent' const catalog = (): PackageView => ({ name, meta: { title }, official: true, availability: 'missing', installed: false, - optional: false, removable: false, enabled: false, rows: [], + optional: false, removable: false, updatable: false, enabled: false, rows: [], installTarget: { spec: `${name}@2.0.0`, version: '2.0.0' }, }) @@ -2430,3 +2504,221 @@ describe('on-demand Official bundles', () => { expect(detail.querySelector('[data-plugin-rows]')).toBeNull() }) }) + +describe('registry versions and updates', () => { + const MIRROR = 'https://registry.npmmirror.com/' + const updatable = (): PackageView => pkg({ updatable: true, version: '0.16.0', source: 'dsh-better-sidebar@0.16.0' }) + const runtime = '0.8.3' + const listed = (versions: PluginVersion[], attempts: RegistryAttempt[] = [{ registry: null }]): VersionCheck => ({ + phase: 'done', checkedAt: 0, + list: { status: 'listed', installed: '0.16.0', registry: attempts.at(-1)?.registry ?? null, attempts, versions, omitted: 0 }, + }) + const v = (version: string, more: Partial = {}): PluginVersion => ({ + version, tags: [], prerelease: false, bundle: true, ...more, + }) + const incompatible = (version: string) => ({ name: 'dsh-better-sidebar', version, runtimeVersion: runtime, requirements: { '@deepseek-ai/dsh': '>=0.9.0' } }) + const detail = (): HTMLElement => document.querySelector('[data-plugin-detail]') as HTMLElement + const section = (): HTMLElement => document.querySelector('[data-plugin-versions]') as HTMLElement + const open = (navigation: ReturnType['navigation']) => { + act(() => { navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + } + + it('checks once on opening, offers the newest installable version, and lists newer versions when unfolded', () => { + const versions = [ + v('2.1.0-beta.1', { prerelease: true, tags: ['next'] }), + v('2.0.0', { tags: ['latest'], incompatible: incompatible('2.0.0'), publishedAt: '2026-10-10T00:00:00.000Z' }), + v('1.4.0', { publishedAt: '2026-09-28T08:00:00.000Z' }), + v('1.3.2', { deprecated: 'use 1.4.0' }), + v('1.3.1', { bundle: false }), + ] + const check = listed(versions, [{ registry: null, problem: 'network', reason: 'ETIMEDOUT' }, { registry: MIRROR }]) + const { actions, navigation } = renderTab({ packages: [updatable()], versions: { 'dsh-better-sidebar': check } }) + expect(screen.getByText(en.statusUpdateAvailable)).toBeTruthy() + open(navigation) + expect(actions.ensureVersions).toHaveBeenCalledWith('dsh-better-sidebar') + expect(actions.ensureVersions).toHaveBeenCalledOnce() + expect(within(section()).getByText('Update to 1.4.0 available')).toBeTruthy() + expect(within(section()).getByText('Published 2026-09-28')).toBeTruthy() + fireEvent.click(within(detail()).getAllByRole('button', { name: 'Update to 1.4.0' })[0]!) + expect(actions.openUpdate).toHaveBeenCalledWith('dsh-better-sidebar', '1.4.0') + const summary = within(section()).getByRole('button', { name: /Update to 1\.4\.0 available/ }) + expect(summary.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(summary) + expect(summary.getAttribute('aria-expanded')).toBe('true') + const rows = [...section().querySelectorAll('[data-plugin-version]')].map(row => row.getAttribute('data-plugin-version')) + expect(rows).toEqual(['2.0.0', '1.4.0', '1.3.2', '1.3.1', '0.16.0']) + const row = (version: string) => section().querySelector(`[data-plugin-version="${version}"]`) as HTMLElement + expect(within(row('2.0.0')).getByText('Needs @deepseek-ai/dsh >=0.9.0')).toBeTruthy() + expect(within(row('2.0.0')).queryByRole('button')).toBeNull() + expect(within(row('1.3.1')).getByText(en.versionsNotBundle)).toBeTruthy() + expect(within(row('1.3.2')).getByText('use 1.4.0')).toBeTruthy() + expect(within(row('0.16.0')).getByText(en.versionsInstalledTag)).toBeTruthy() + fireEvent.click(within(row('1.3.2')).getByRole('button', { name: 'Update to 1.3.2' })) + expect(actions.openUpdate).toHaveBeenLastCalledWith('dsh-better-sidebar', '1.3.2') + // The registry that answered, marked as a fallback after the first one failed. + expect(within(section()).getByText(en.registryNpmmirror)).toBeTruthy() + expect(within(section()).getByText(en.versionsFallbackTag)).toBeTruthy() + fireEvent.click(within(section()).getByRole('checkbox', { name: en.versionsShowPrerelease })) + expect(row('2.1.0-beta.1')).toBeTruthy() + fireEvent.click(within(section()).getByRole('button', { name: en.versionsRecheck })) + expect(actions.checkVersions).toHaveBeenCalledWith('dsh-better-sidebar') + fireEvent.click(summary) + expect(row('1.4.0')).toBeNull() + }) + + it.each([ + ['checking', { phase: 'checking' }, en.versionsChecking], + ['up to date', listed([]), en.versionsLatest], + ['blocked', listed([v('2.0.0', { incompatible: incompatible('2.0.0') })]), en.versionsBlocked], + ['not installable', listed([v('1.0.0', { deprecated: 'old' })]), en.versionsNoneInstallable], + ] as const)('summarizes a %s check', (_name, check, title) => { + const { navigation } = renderTab({ packages: [updatable()], versions: { 'dsh-better-sidebar': check } }) + open(navigation) + expect(within(section()).getByText(title)).toBeTruthy() + expect(within(detail()).queryByRole('button', { name: /^Update to / })).toBeNull() + if (title === en.versionsBlocked) expect(within(section()).getByText(`2.0.0 needs @deepseek-ai/dsh >=0.9.0; this DSH is ${runtime}`)).toBeTruthy() + }) + + it('reports a refused check with the registries asked and a retry', () => { + const attempts: RegistryAttempt[] = [ + { registry: null, problem: 'network', reason: 'pnpm view timed out after 20000ms' }, + { registry: MIRROR, problem: 'network', reason: 'getaddrinfo ENOTFOUND registry.npmmirror.com' }, + ] + const { actions, navigation } = renderTab({ packages: [updatable()], versions: { + 'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'network', reason: 'down', attempts } }, + } }) + open(navigation) + expect(within(section()).getByText(en.versionsFailedNetwork)).toBeTruthy() + expect(within(section()).getByText(`${en.registryNpmmirror} ${en.versionsProblemNetwork}`)).toBeTruthy() + expect(within(section()).queryByRole('button', { name: en.versionsRecheck })).toBeNull() + fireEvent.click(within(section()).getByRole('button', { name: en.versionsDetails })) + expect(within(section()).getByText('getaddrinfo ENOTFOUND registry.npmmirror.com')).toBeTruthy() + expect(within(section()).getByText(en.versionsPrimaryTag)).toBeTruthy() + fireEvent.click(within(section()).getByRole('button', { name: en.retry })) + expect(actions.checkVersions).toHaveBeenCalledWith('dsh-better-sidebar') + }) + + it('reports a check the transport lost by its words, and lists a single registry\'s answer without a fallback mark', () => { + const { navigation, set } = renderTab({ packages: [updatable()], versions: { + 'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'unknown', reason: 'socket closed', attempts: [] } }, + } }) + open(navigation) + expect(within(section()).getByText(en.versionsFailedOther)).toBeTruthy() + expect(within(section()).getByText('socket closed')).toBeTruthy() + expect(within(section()).queryByRole('button', { name: en.versionsDetails })).toBeNull() + // A refused list can name a registry that answered before a later lookup failed. + set({ versions: { 'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'unknown', reason: 'boom', attempts: [{ registry: null }] } } } }) + fireEvent.click(within(section()).getByRole('button', { name: en.versionsDetails })) + expect(within(section()).getByText(en.registryDefault)).toBeTruthy() + expect(within(section()).queryByText(en.versionsProblemUnknown)).toBeNull() + set({ versions: { 'dsh-better-sidebar': { ...listed([v('0.17.0')]), list: { ...(listed([v('0.17.0')]) as Extract).list, omitted: 3 } } as VersionCheck } }) + expect(within(section()).queryByText(/^Published/)).toBeNull() + // The section stays unfolded across the new answer. + expect(within(section()).getByRole('button', { name: /Update to 0\.17\.0 available/ }).getAttribute('aria-expanded')).toBe('true') + expect(within(section()).getByText('3 more versions newer than the installed one are not listed')).toBeTruthy() + expect(within(section()).queryByText(en.versionsFallbackTag)).toBeNull() + expect(within(section()).queryByRole('checkbox')).toBeNull() + }) + + it('shows no Versions section for a package that is not updatable', () => { + const { actions, navigation } = renderTab({ packages: [pkg({ source: 'link:/dev/dsh-better-sidebar' })] }) + open(navigation) + expect(section()).toBeNull() + expect(actions.ensureVersions).not.toHaveBeenCalled() + }) + + it('says in the toolbar what the version checks found', () => { + const { set } = renderTab({ packages: [updatable(), pkg({ name: 'dsh-linked' })] }) + set({ versions: { 'dsh-better-sidebar': { phase: 'checking' } } }) + expect(screen.queryByText(en.updatesNone)).toBeNull() + set({ versions: { 'dsh-better-sidebar': listed([v('1.0.0')]) } }) + expect(screen.getByText('Updates available: 1')).toBeTruthy() + set({ versions: { 'dsh-better-sidebar': listed([]) } }) + expect(screen.getByText(en.updatesNone)).toBeTruthy() + set({ versions: { 'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'unknown', reason: 'x', attempts: [] } } } }) + expect(screen.getByText(en.updatesFailed)).toBeTruthy() + }) + + const base: UpdateState = { packageName: 'dsh-better-sidebar', from: '1.3.0', to: '1.4.0', registry: MIRROR, phase: 'confirm', steps: [], result: null, outputOpen: false } + const change = (application: ChangeResult['application'], more: Partial = {}): ChangeResult => ({ + changed: true, application, stage: 'install', target: 'dsh-better-sidebar', ...more, + }) + const dialog = (): HTMLElement => screen.getByRole('dialog') + + it('confirms an update with its versions and registry, and runs it', () => { + const { actions } = renderTab({ packages: [updatable()], update: base }) + expect(within(dialog()).getByText('1.3.0')).toBeTruthy() + expect(within(dialog()).getByText(en.registryNpmmirror)).toBeTruthy() + expect(within(dialog()).getByText(en.updateDescription.replaceAll('{from}', '1.3.0').replace('{to}', '1.4.0'))).toBeTruthy() + fireEvent.click(within(dialog()).getByRole('button', { name: en.update })) + expect(actions.runUpdate).toHaveBeenCalledOnce() + fireEvent.click(within(dialog()).getByRole('button', { name: en.cancel })) + expect(actions.closeUpdate).toHaveBeenCalledOnce() + }) + + it('names a package that left the list by its package name', () => { + renderTab({ packages: [], update: base }) + expect(within(dialog()).getByText(en.updateTitle.replace('{name}', 'dsh-better-sidebar'))).toBeTruthy() + }) + + it('follows the running steps and keeps the dialog open', () => { + const { actions } = renderTab({ packages: [updatable()], update: { ...base, phase: 'running', steps: ['checking', 'removing'] } }) + const steps = [...dialog().querySelectorAll('li')].map(item => [item.getAttribute('data-state'), item.textContent]) + expect(steps).toEqual([['done', 'Check 1.4.0'], ['running', 'Stop and remove 1.3.0']]) + expect(within(dialog()).getByRole('button', { name: en.updateRunning }).disabled).toBe(true) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(actions.closeUpdate).not.toHaveBeenCalled() + }) + + it.each([ + ['applied', { step: 'install', result: change('applied') }, 'Updated to 1.4.0', [['done', 'Check 1.4.0'], ['done', `Install 1.4.0 from ${en.registryNpmmirror}`]]], + ['restart', { step: 'install', result: change('restart-required', { restartReason: 'host-module' }) }, '1.4.0 installed; it takes effect after a restart', [['done', 'Check 1.4.0'], ['done', `Install 1.4.0 from ${en.registryNpmmirror}`]]], + ['refused', { step: 'check', result: change('failed', { error: { code: 'incompatible-version' } }) }, 'Cannot update to 1.4.0', [['failed', 'Check 1.4.0']]], + ['removal failure', { step: 'remove', result: change('failed', { stage: 'remove', error: { code: 'operation-error', diagnostic: 'EBUSY' } }) }, 'Could not remove 1.3.0', [['done', 'Check 1.4.0'], ['failed', 'Stop and remove 1.3.0']]], + ['restored', { + step: 'install', result: change('failed', { packageResult: { exitCode: 1, output: '\nERR_PNPM_FETCH_404 Not Found', truncated: false, logPath: '/l', kind: 'not-found' } }), + restore: change('applied'), + }, 'Update failed; restored 1.3.0', [['done', 'Check 1.4.0'], ['failed', `Install 1.4.0 from ${en.registryNpmmirror}`], ['done', 'Install 1.3.0 again']]], + ['kept', { step: 'install', result: change('failed', { + error: { code: 'operation-error', diagnostic: 'ETARGET' }, packageResult: { exitCode: 1, output: 'ETARGET', truncated: false, logPath: '/l' }, + }) }, 'Could not update to 1.4.0', [['done', 'Check 1.4.0'], ['failed', `Install 1.4.0 from ${en.registryNpmmirror}`]]], + ['broken', { step: 'install', result: change('cancelled'), restore: change('failed', { packageResult: { exitCode: 1, output: 'gone', truncated: false, logPath: '/l' } }) }, + en.updateBroken, [['done', 'Check 1.4.0'], ['failed', `Install 1.4.0 from ${en.registryNpmmirror}`], ['failed', 'Install 1.3.0 again']]], + ] as const)('reports a %s outcome with the steps its answer implies', (_name, answer, title, steps) => { + const result = { from: '1.3.0', to: '1.4.0', ...answer } as PluginUpdateResult + const { actions } = renderTab({ packages: [updatable()], update: { ...base, phase: 'done', result } }) + expect(within(dialog()).getByText(title)).toBeTruthy() + expect([...dialog().querySelectorAll('li')].map(item => [item.getAttribute('data-state'), item.textContent])).toEqual(steps) + const output = within(dialog()).queryByRole('button', { name: en.updateOutput }) + expect(output !== null).toBe(_name === 'restored' || _name === 'broken' || _name === 'kept') + if (_name === 'kept') expect(within(dialog()).getByText('1.3.0 was not removed and stays in use. ETARGET')).toBeTruthy() + if (output !== null) { + fireEvent.click(output) + expect(actions.toggleUpdateOutput).toHaveBeenCalledOnce() + } + fireEvent.click(within(dialog()).getByRole('button', { name: en.updateFinish })) + expect(actions.closeUpdate).toHaveBeenCalledOnce() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(actions.closeUpdate).toHaveBeenCalledTimes(2) + }) + + it('lists the steps the answer implies beside the announced ones', () => { + renderTab({ packages: [updatable()], update: { + ...base, phase: 'done', steps: ['checking', 'removing'], + result: { from: '1.3.0', to: '1.4.0', step: 'install', result: change('failed'), restore: change('applied') }, + } }) + expect([...dialog().querySelectorAll('li')].map(item => item.getAttribute('data-state'))).toEqual(['done', 'done', 'failed', 'done']) + }) + + it('shows a failed run\'s output once unfolded and an unknown outcome after a lost answer', () => { + const failedRun = change('failed', { packageResult: { exitCode: 1, output: 'ERR_PNPM_FETCH_404', truncated: false, logPath: '/l' } }) + const { set } = renderTab({ packages: [updatable()], update: { + ...base, phase: 'done', outputOpen: true, steps: ['checking', 'removing', 'installing', 'restoring'], + result: { from: '1.3.0', to: '1.4.0', step: 'install', result: failedRun, restore: change('restart-required') }, + } }) + expect(within(dialog()).getByText('ERR_PNPM_FETCH_404').tagName).toBe('PRE') + set({ update: { ...base, phase: 'done', steps: ['checking', 'removing'], lost: 'socket closed' } }) + expect(within(dialog()).getByText(en.updateLost)).toBeTruthy() + expect([...dialog().querySelectorAll('li')].map(item => item.getAttribute('data-state'))).toEqual(['done', 'unknown']) + }) +}) diff --git a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts new file mode 100644 index 0000000000..b57cee89f6 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts @@ -0,0 +1,125 @@ +import { Context } from '@deepseek-ai/cordis' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import type { BundleDetail } from '@deepseek-ai/dsh-api-remotes/client' +import { expect, it, onTestFinished, vi } from 'vitest' +import { BundleDetailController, bundleDetailKey } from '../src/client/detail-store.ts' + +type Answer = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: RemoteError } +const ok = (value: T): Promise> => Promise.resolve({ ok: true, value }) +const FOUND: BundleDetail = { status: 'found', origin: 'readme', path: 'README.md', markdown: '# A' } +const flush = () => new Promise((resolve) => { setTimeout(resolve, 0) }) + +function setup() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const manager = { + readBundleDetail: vi.fn((_name: string, _language: string, _options: object): Promise> => ok(FOUND)), + } + const probe = { fastest: vi.fn((): Promise> => ok('https://registry.npmmirror.com/')) } + new TestRemote(ctx, { pluginManager: manager, pluginRegistryProbe: probe }) + const controller = new BundleDetailController(ctx, () => 'https://dsh.example/prefix/') + const state = (name: string, version: string | undefined, language: string) => + controller.store.getSnapshot()[bundleDetailKey(name, version, language)] + return { controller, manager, probe, state } +} + +it('reads each bundle version once per language and publishes the result', async () => { + const { controller, manager, state } = setup() + controller.load('a', '1.0.0', 'en', false) + controller.load('a', '1.0.0', 'en', false) + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' }) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) + controller.load('a', undefined, 'zh', false) + await vi.waitFor(() => { expect(state('a', undefined, 'zh')?.status).toBe('ready') }) + expect(manager.readBundleDetail.mock.calls).toEqual([['a', 'en', { npmFileSources: [] }], ['a', 'zh', { npmFileSources: [] }]]) +}) + +it('reports failures by reason, reads again on retry, and settles a retried key with the latest answer', async () => { + const { controller, manager, state } = setup() + manager.readBundleDetail.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('plugin-manager/detail-unavailable' as never, 'big', { reason: 'too-large' } as never) })) + manager.readBundleDetail.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('plugin-manager/unknown-bundle' as never, 'gone', { name: 'b' } as never) })) + controller.load('a', '1.0.0', 'en', false) + controller.load('b', '1.0.0', 'en', false) + await vi.waitFor(() => { + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'error', failure: 'too-large' }) + expect(state('b', '1.0.0', 'en')).toEqual({ status: 'error', failure: 'other' }) + }) + let first: (value: Answer) => void = () => {} + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { first = resolve })) + controller.retry('a', '1.0.0', 'en', false) + controller.retry('a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) + first({ ok: true, value: { status: 'none' } }) + await flush() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) +}) + +it('forgets every read on reset, drops answers of forgotten reads, and stops publishing after disposal', async () => { + const { controller, manager, state } = setup() + let answer: (value: Answer) => void = () => {} + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + controller.load('a', '1.0.0', 'en', false) + controller.reset() + answer({ ok: true, value: FOUND }) + await flush() + expect(controller.store.getSnapshot()).toEqual({}) + // A read from before a reset that settles after the read that replaced it does not overwrite it. + let stale: (value: Answer) => void = () => {} + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { stale = resolve })) + controller.load('a', '1.0.0', 'en', false) + controller.reset() + manager.readBundleDetail.mockImplementationOnce(() => ok({ status: 'none' })) + controller.load('a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + stale({ ok: true, value: FOUND }) + await flush() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } }) + controller.reset() + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + controller.load('a', '1.0.0', 'en', false) + controller.dispose() + answer({ ok: true, value: FOUND }) + await flush() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' }) +}) + +it('asks the registry comparison only for bundles read from npm, and reads them and their images from npmmirror first where it won', async () => { + const { controller, manager, probe, state } = setup() + controller.load('installed', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('installed', '1.0.0', 'en')?.status).toBe('ready') }) + expect(probe.fastest).not.toHaveBeenCalled() + expect(controller.image('x', 'README.md', 'a.png', true)).not.toContain('sources=') + controller.load('offered', '1.0.0', 'en', true) + await vi.waitFor(() => { expect(state('offered', '1.0.0', 'en')?.status).toBe('ready') }) + expect(manager.readBundleDetail.mock.calls.at(-1)).toEqual(['offered', 'en', { npmFileSources: ['npmmirror'] }]) + expect(controller.image('offered', 'README.md', 'a.png', true)).toContain('&sources=npmmirror') + expect(controller.image('installed', 'README.md', 'a.png', false)).not.toContain('sources=') + probe.fastest.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('remote/unavailable' as never, 'gone', {} as never) })) + controller.retry('offered', '1.0.0', 'en', true) + await vi.waitFor(() => { expect(manager.readBundleDetail.mock.calls.at(-1)).toEqual(['offered', 'en', { npmFileSources: [] }]) }) +}) + +it('drops a read whose registry comparison settles after a reset or disposal, without asking the Host', async () => { + const { controller, manager, probe, state } = setup() + let settle: (value: Answer) => void = () => {} + probe.fastest.mockImplementation(() => new Promise((resolve) => { settle = resolve })) + controller.load('offered', '1.0.0', 'en', true) + controller.reset() + settle({ ok: true, value: 'https://registry.npmmirror.com/' }) + await flush() + controller.load('offered', '1.0.0', 'en', true) + controller.dispose() + settle({ ok: true, value: 'https://registry.npmmirror.com/' }) + await flush() + expect(manager.readBundleDetail).not.toHaveBeenCalled() + expect(state('offered', '1.0.0', 'en')).toEqual({ status: 'loading' }) +}) + +it('addresses relative images through the Host route and leaves other destinations alone', () => { + const { controller } = setup() + expect(controller.image('@acme/x', 'guide/D.md', 'img/a b.png', false)) + .toBe('https://dsh.example/prefix/api/plugin-manager/detail-file?bundle=%40acme%2Fx&document=guide%2FD.md&path=img%2Fa+b.png') + for (const destination of ['https://example.com/a.png', 'data:image/png;base64,AA', '//cdn.example/a.png', '#top']) { + expect(controller.image('x', 'README.md', destination, false)).toBeUndefined() + } +}) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index c4592690da..6b8f2e2968 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -9,7 +9,7 @@ import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginTab } from '../src/client/tab-ledger.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' -import { offeredRegistries, packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' +import { installable, offeredRegistries, packageView, PluginManagerController, rowKey, sortPackages, updateCandidate } from '../src/client/manager-store.ts' const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', requirements: { '@deepseek-ai/dsh': '^0.2.0' } } const ROW_ENTRY = 'include:sidebar' as PluginEntryId @@ -25,6 +25,7 @@ const BUNDLE: BundleInfo = { source: 'dsh-better-sidebar@^0.16.0', optional: false, removable: true, + updatable: false, rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }], overrides: [], } @@ -122,7 +123,7 @@ describe('packageView', () => { it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0', - installed: true, official: false, availability: 'profile', optional: false, removable: true, enabled: false, + installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: false, enabled: false, rows: [ { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null }, @@ -130,13 +131,13 @@ describe('packageView', () => { }) // A row the inventory no longer lists, a protected row, and a bundle the Host cannot read. const protectedBundle: BundleInfo = { - name: '@deepseek-ai/dsh-base', enabled: true, installed: false, official: false, availability: 'installation', optional: false, removable: false, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', enabled: true, installed: false, official: false, availability: 'installation', optional: false, removable: false, updatable: false, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, rows: [{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core' as PluginEntryId }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone' as PluginEntryId }], overrides: [], } expect(packageView(protectedBundle, PLUGINS)).toEqual({ - name: '@deepseek-ai/dsh-base', installed: false, official: false, availability: 'installation', optional: false, removable: false, enabled: true, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', installed: false, official: false, availability: 'installation', optional: false, removable: false, updatable: false, enabled: true, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, rows: [ { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' }, @@ -148,7 +149,7 @@ describe('packageView', () => { describe('sortPackages', () => { it('orders packages by the short name a person reads, not by the Host order or enablement', async () => { - const plain = { enabled: true, installed: true, official: false, availability: 'profile' as const, optional: false, removable: true, rows: [], overrides: [] } + const plain = { enabled: true, installed: true, official: false, availability: 'profile' as const, optional: false, removable: true, updatable: false, rows: [], overrides: [] } const zeta: BundleInfo = { ...plain, name: 'dsh-zeta' } const alpha: BundleInfo = { ...plain, name: '@acme/dsh-alpha', enabled: false } const views = [zeta, BUNDLE, alpha].map(bundle => packageView(bundle, PLUGINS)) @@ -1832,7 +1833,7 @@ it('reports a successful install with only documented result fields', async () = const CATALOG: BundleInfo = { name: '@deepseek-ai/dsh-subagent-codex', official: true, availability: 'missing', installed: false, optional: false, - enabled: false, removable: false, rows: [], overrides: [], + enabled: false, removable: false, updatable: false, rows: [], overrides: [], installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' }, } @@ -2048,3 +2049,158 @@ it('deselects a source bundle whose target is unavailable without starting insta await vi.waitFor(() => { expect(plugins.setBundleEnabled).toHaveBeenCalledWith(entry.name, false) }) expect(plugins.installBundle).not.toHaveBeenCalled() }) + +describe('version checks and updates', () => { + const UPDATABLE: BundleInfo = { ...BUNDLE, updatable: true } + const LISTED = { + status: 'listed' as const, installed: '0.16.0', registry: MIRROR, omitted: 0, + attempts: [{ registry: null, problem: 'network' as const, reason: 'ETIMEDOUT' }, { registry: MIRROR }], + versions: [ + { version: '1.0.0', tags: ['latest'], prerelease: false, bundle: true, incompatible: INCOMPATIBLE }, + { version: '0.18.0-beta.1', tags: [], prerelease: true, bundle: true }, + { version: '0.17.1', tags: [], prerelease: false, bundle: true, deprecated: 'broken' }, + { version: '0.17.0', tags: [], prerelease: false, bundle: true }, + ], + } + const UPDATED = { from: '0.16.0', to: '0.17.0', step: 'install' as const, result: { ...APPLIED, version: '0.17.0' } } + + it('offers the newest version that declares a bundle, accepts this DSH, and is neither deprecated nor a prerelease', () => { + expect(updateCandidate(LISTED)?.version).toBe('0.17.0') + expect(updateCandidate({ ...LISTED, versions: LISTED.versions.slice(0, 3) })).toBeUndefined() + expect(updateCandidate({ status: 'refused', problem: 'network', reason: 'down', attempts: [] })).toBeUndefined() + expect(installable({ version: '2.0.0', tags: [], prerelease: false, bundle: false })).toBe(false) + }) + + it('checks a package once on ensure, again on request, and keeps a transport failure as an unknown refusal', async () => { + const listVersions = vi.fn().mockResolvedValueOnce(ok(LISTED)).mockResolvedValueOnce(refused('gateway/internal', 'offline')) + const { face, state } = bench({ listVersions }) + face.ensureVersions('dsh-better-sidebar') + expect(state().versions['dsh-better-sidebar']).toEqual({ phase: 'checking' }) + // A check in flight is not started again. + face.checkVersions('dsh-better-sidebar') + await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']).toMatchObject({ phase: 'done', list: LISTED }) }) + face.ensureVersions('dsh-better-sidebar') + expect(listVersions).toHaveBeenCalledOnce() + expect(listVersions).toHaveBeenCalledWith('dsh-better-sidebar', {}) + face.checkVersions('dsh-better-sidebar') + await vi.waitFor(() => { + expect(state().versions['dsh-better-sidebar']).toMatchObject({ phase: 'done', list: { status: 'refused', problem: 'unknown', reason: 'offline', attempts: [] } }) + }) + expect(listVersions).toHaveBeenCalledTimes(2) + }) + + it('forgets a check answered for another installed version, or for a package that is no longer updatable', async () => { + const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE, { ...UPDATABLE, name: 'dsh-a' }]))) + const { controller, face, state } = bench({ listBundles, listVersions: vi.fn(() => Promise.resolve(ok(LISTED))) }) + await controller.load() + face.checkVersions('dsh-better-sidebar') + face.checkVersions('dsh-a') + await vi.waitFor(() => { expect(state().versions['dsh-a']?.phase).toBe('done') }) + await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') }) + // Another surface reinstalled one package at another version and replaced the other by a linked copy. + listBundles.mockResolvedValue(ok([{ ...UPDATABLE, version: '0.17.0' }, { ...UPDATABLE, name: 'dsh-a', updatable: false }])) + await controller.load() + expect(state().versions).toEqual({}) + }) + + it('checks every updatable package in turn on a manual refresh and keeps the spinner until the checks settle', async () => { + const order: string[] = [] + const last = deferred>>() + const listVersions = vi.fn(async (name: string) => { order.push(name); return name === 'dsh-better-sidebar' ? last.promise : ok(LISTED) }) + const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE, { ...UPDATABLE, name: 'dsh-a' }, { ...BUNDLE, name: 'dsh-linked' }]))) + const { face, state } = bench({ listVersions, listBundles }) + face.refresh() + await vi.waitFor(() => { expect(order).toEqual(['dsh-a', 'dsh-better-sidebar']) }) + expect(state().refreshStatus).toBe('refreshing') + last.resolve(ok(LISTED)) + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') + }) + + it('checks no versions after a failed refresh', async () => { + const listVersions = vi.fn(() => Promise.resolve(ok(LISTED))) + const listBundles = vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))) + const { face, state } = bench({ listVersions, listBundles }) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') }) + expect(listVersions).not.toHaveBeenCalled() + }) + + it('confirms, runs, and keeps an update\'s steps and outcome, then checks the package again', async () => { + const answer = deferred>>() + const updateBundle = vi.fn(() => answer.promise) + const listVersions = vi.fn(() => Promise.resolve(ok(LISTED))) + const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE]))) + const { controller, face, state, plugins } = bench({ updateBundle, listVersions, listBundles }) + await controller.load() + // Nothing to confirm before the package's versions are read. + face.openUpdate('dsh-better-sidebar', '0.17.0') + expect(state().update).toBeNull() + face.checkVersions('dsh-better-sidebar') + await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') }) + face.openUpdate('dsh-better-sidebar', '0.17.0') + expect(state().update).toEqual({ + packageName: 'dsh-better-sidebar', from: '0.16.0', to: '0.17.0', registry: MIRROR, phase: 'confirm', steps: [], result: null, outputOpen: false, + }) + face.closeUpdate() + expect(state().update).toBeNull() + face.openUpdate('dsh-better-sidebar', '0.17.0') + face.runUpdate() + expect(state().update).toMatchObject({ phase: 'running' }) + const requestId = state().update?.requestId as PluginInstallRequestId + expect(updateBundle).toHaveBeenCalledWith('dsh-better-sidebar', '0.17.0', { registry: MIRROR, requestId }) + face.runUpdate() + face.closeUpdate() + expect(updateBundle).toHaveBeenCalledOnce() + expect(state().update?.phase).toBe('running') + // Progress of another request, and a repeated step, change nothing. + controller.updateProgress({ requestId: 'other' as PluginInstallRequestId, step: 'checking' }) + controller.updateProgress({ requestId, step: 'checking' }) + controller.updateProgress({ requestId, step: 'checking' }) + controller.updateProgress({ requestId, step: 'removing' }) + expect(state().update?.steps).toEqual(['checking', 'removing']) + face.openUpdate('dsh-better-sidebar', '1.0.0') + expect(state().update?.to).toBe('0.17.0') + // A check of the package being replaced waits for the update. + face.checkVersions('dsh-better-sidebar') + expect(listVersions).toHaveBeenCalledOnce() + const reads = plugins.listBundles.mock.calls.length + answer.resolve(ok(UPDATED)) + await vi.waitFor(() => { expect(state().update).toMatchObject({ phase: 'done', result: UPDATED, steps: ['checking', 'removing'] }) }) + expect(plugins.listBundles.mock.calls.length).toBeGreaterThan(reads) + // The package's versions are read again for the version now installed. + await vi.waitFor(() => { expect(listVersions).toHaveBeenCalledTimes(2) }) + await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') }) + controller.updateProgress({ requestId, step: 'installing' }) + expect(state().update?.steps).toEqual(['checking', 'removing']) + face.toggleUpdateOutput() + expect(state().update?.outputOpen).toBe(true) + face.closeUpdate() + expect(state().update).toBeNull() + face.toggleUpdateOutput() + expect(state().update).toBeNull() + }) + + it('keeps a lost update answer as unknown and ignores an answer after disposal', async () => { + const updateBundle = vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'socket closed')) + const listVersions = vi.fn(() => Promise.resolve(ok(LISTED))) + const { controller, face, state } = bench({ updateBundle, listVersions, listBundles: vi.fn(() => Promise.resolve(ok([UPDATABLE]))) }) + await controller.load() + face.checkVersions('dsh-better-sidebar') + await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') }) + face.openUpdate('dsh-better-sidebar', '0.17.0') + face.runUpdate() + await vi.waitFor(() => { expect(state().update).toMatchObject({ phase: 'done', result: null, lost: 'socket closed' }) }) + const late = deferred>>() + updateBundle.mockReturnValueOnce(late.promise) + await vi.waitFor(() => { expect(listVersions).toHaveBeenCalledTimes(2) }) + await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') }) + face.closeUpdate() + face.openUpdate('dsh-better-sidebar', '0.17.0') + face.runUpdate() + controller.dispose() + late.resolve(ok(UPDATED)) + await late.promise + expect(state().update?.phase).toBe('running') + }) +}) diff --git a/packages/client/ui-plugin-market/README.i18n.yaml b/packages/client/ui-plugin-market/README.i18n.yaml index 4223a3e430..4ca8e13747 100644 --- a/packages/client/ui-plugin-market/README.i18n.yaml +++ b/packages/client/ui-plugin-market/README.i18n.yaml @@ -9,17 +9,17 @@ en: 62972eb0dff8c145 zh: 682cc03b492cd730 /deepseek-ai-dsh-client-ui-plugin-market/summary: - en: f95719fc57b4e921 - zh: bf8a63b54127c147 + en: 55a3d1b12cd7be61 + zh: a961cf0212b3fda8 /deepseek-ai-dsh-client-ui-plugin-market/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-plugin-market/use-this-package: - en: 2690b95e156cc473 - zh: 0d9e897ad628f9dd + en: f873bf36195df821 + zh: e13c78ba3f3e29f1 /deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation: - en: c56790cb58083f85 - zh: a3d4ad4bad08e483 + en: 4107a6967934f6b6 + zh: 46acec8bedb86329 /deepseek-ai-dsh-client-ui-plugin-market/model-experience: en: 08d25ab9cbdd7567 zh: 26e6e75c123951ee @@ -27,8 +27,8 @@ en: 4f0eadf94828e66e zh: 4f8a6ec170ebbfb6 /deepseek-ai-dsh-client-ui-plugin-market/known-limitations-and-deferred-work: - en: 5e98bb00293c12e3 - zh: 279b09a42c515d17 + en: 493d1326b1645f3f + zh: 878a53ed6cb7878d /deepseek-ai-dsh-client-ui-plugin-market/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-plugin-market/README.md b/packages/client/ui-plugin-market/README.md index b89f158b64..7a2bf9ffa8 100644 --- a/packages/client/ui-plugin-market/README.md +++ b/packages/client/ui-plugin-market/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. +Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. Entry pages show each entry's long description as Markdown with images. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. ## Table of Contents @@ -28,12 +28,14 @@ Open **Plugins** and select **Discover**. With no index source, the tab shows an The toolbar holds the market menu, which switches markets and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the market declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the market declares categories. -Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. A **Versions** list follows in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. +Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. **Install** chooses the default version and opens a confirmation dialog with the entry's icon, title, that version, the author, its source facts, an exemption notice when one applies, and a trust notice. Confirming opens the Plugins install dialog at its check screen with the entry's exact spec and registry; there is no spec form, and a refusal offers **Retry**. The registry an entry names applies to that install only. The Host [`pluginMarket.search`](../../boot/plugin-market/README.md#use-this-package) matches, filters, orders, and pages; the tab sends the search text 250 ms after typing stops. Search matches names, authors, keywords, and every language of titles, descriptions, categories, and labels, fuzzily. Label filters must all match. **Sort** offers **Best match** while there is search text, **Default order**, **Publish date**, and each declared sort key, which starts in its declared direction; a directed sort adds a row that reverses it. Typing switches **Default order** to **Best match**, and clearing the text switches back. Categories, labels, and sort keys are listed by localized title. The list loads 50 entries and adds 50 with each **Show more**. +The description section shows the default version's long description in the active UI language: the version's locale `meta.detail` file, otherwise its package README, otherwise its repository README, in the [order `pluginMarket` documents](../../boot/plugin-market/README.md#plugin-detail-documents). While the document loads, the section shows three skeleton lines; an entry without a document shows no section; a failed read names its reason, such as a size limit, an unreachable source, or a refused repository, with **Retry**. Raw HTML is omitted, and a document taller than the section's height limit scrolls inside it. Relative image paths load from the Host beside the document, absolute HTTP(S) images load directly without a referrer, and relative links stay text. The section is the shared [`DetailDocument`](../ui-primitives/README.md#component-catalog) primitive. + Reading a market shows card skeletons. A failed read shows a short sentence with **Retry**, the Host's reason appears under the source in **Index sources**, and keeps the market's last listing; a failed read of the source list shows **Retry** in place of the tab. Discover re-reads sources on `plugin-market/changed` and connection reset, and recomputes installation status on `plugin-manager/changed`. ----- @@ -41,6 +43,8 @@ Reading a market shows card skeletons. A failed read shows a short sentence with ## Understand the implementation +Detail reads are kept in a second snapshot store keyed by index URL, entry, version, and language; a section starts its read when it renders, an npm version's read first asks the Host's registry comparison and passes `npmFileSources: ['npmmirror']` when npmmirror answered fastest, only the latest read of a key settles it, **Retry** asks the Host to read again instead of returning its cached result, and a connection reset forgets every read, because a restarted Host no longer knows the document ids its images use, so each shown section reads its document again. Image URLs resolve `api/plugin-market/detail-file` against `document.baseURI`, so they keep a deployment prefix and use the Desktop `dsh-app://app/` origin, which [`MarkdownText`](../ui-primitives/README.md) accepts for that route. + The plugin registers the `discover` entry, labelled **Discover**, into `plugins.tab`, the tab slot that [`ui-plugin-manager`](../ui-plugin-manager/README.md) declares; the owner props provide `install({ spec, registry?, title, version })` and `openPackage(name)`; Discover passes the entry's localized title and the chosen version. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another market starts from the initial query. Later pages send the first page's `fetchedAt`, and a page from a replaced document reloads the list once; a second replacement fails the read. The listing keeps the query its results answer, so the empty-state wording follows the shown results while a new query loads or after it fails, and **Retry** repeats the read that failed: the first pages of a query or the next page. A change to the profile's bundles reloads every loaded page to refresh installation status, and a filter or sort key the index no longer declares leaves the query. The unsent search text, the open entry, and the confirmation are component state. Pure helpers in `presentation.ts` derive the query order from the text, drop undeclared ids, order declarations with `Intl.Collator` in the active UI language, and list source facts. Icons load lazily with `referrerpolicy="no-referrer"` and fall back to the default artwork. ----- @@ -59,6 +63,7 @@ None. - **No upgrade action** — an available update is shown as a status only. +- **Plain Markdown only** — detail documents omit raw HTML, including HTML images and layout, and relative links, including in-page anchors, are not clickable. - **Remote icons** — URL icons are requested by the browser, which discloses the user's IP address to the image host. When the Web UI is served over HTTPS, the browser blocks or upgrades `http:` icons from an `http:` index, and a failed icon shows the default artwork. diff --git a/packages/client/ui-plugin-market/README.zh.md b/packages/client/ui-plugin-market/README.zh.md index c5d906e802..40b98ec314 100644 --- a/packages/client/ui-plugin-market/README.zh.md +++ b/packages/client/ui-plugin-market/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 +「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 ## 目录 @@ -28,12 +28,14 @@ kind: "package-reference" 工具栏包含:插件市场菜单,用于切换插件市场,最后一项为**管理索引源…**;搜索框;插件市场声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件市场声明了分类时,其后显示分类按钮,**全部**排在第一位。 -每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示和默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。其后是按 SemVer 降序排列的**版本**列表;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 +每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 **安装**选择默认版本并打开确认对话框,显示条目的图标、标题、该版本、作者、来源信息、适用时的豁免提示,以及信任提示。确认后,插件页的安装对话框以该条目的精确 spec 和注册表在检查界面打开;不显示 spec 表单,被拒绝时提供**重试**。条目指定的注册表只用于这一次安装。 Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this-package) 负责匹配、过滤、排序和分页;标签页在停止输入 250 毫秒后发送搜索文本。搜索模糊匹配名称、作者、关键词,以及标题、描述、分类和标签的所有语言。所选标签必须全部匹配。有搜索文本时,**排序**提供**最佳匹配**,此外还提供**默认顺序**、**发布时间**以及每个已声明的排序键,排序键以其声明的方向开始;有方向的排序会额外显示一行,用于反转方向。输入文本会把**默认顺序**切换为**最佳匹配**,清空文本时切换回来。分类、标签和排序键按本地化标题列出。列表加载 50 个条目,每次点击**显示更多**再增加 50 个。 +详细介绍部分以当前界面语言显示默认版本的详细介绍:按 [`pluginMarket` 说明的顺序](../../boot/plugin-market/README.zh.md#plugin-detail-documents),依次为该版本 locale 中 `meta.detail` 指定的文件、其包 README、其仓库 README。文档加载期间,该部分显示三行骨架屏;没有文档的条目不显示该部分;读取失败时说明原因,例如超过大小限制、来源无法访问或仓库拒绝访问,并提供**重试**。原始 HTML 会被省略;文档高度超过该部分的高度上限时,在该部分内滚动。相对路径的图片从 Host 上该文档旁的位置加载,绝对 HTTP(S) 地址的图片不带 referrer 直接加载,相对链接保持为文本。该部分使用共享的 [`DetailDocument`](../ui-primitives/README.zh.md#component-catalog) 原语。 + 读取插件市场时显示卡片骨架屏。读取失败时显示一句简短说明和**重试**,Host 给出的原因显示在**索引源**中对应索引源的下方,并保留该插件市场上一次的列表;读取索引源列表失败时,在该标签页的位置显示**重试**。「发现」在收到 `plugin-market/changed` 和连接重置时重新读取索引源,在收到 `plugin-manager/changed` 时重新计算安装状态。 ----- @@ -41,6 +43,8 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- ## 理解实现 +详细介绍的读取结果保存在第二个快照存储中,以索引 URL、条目、版本和语言为键;该部分在渲染时开始读取;npm 版本的读取先询问 Host 的注册表比较,npmmirror 响应最快时传入 `npmFileSources: ['npmmirror']`;只有某个键最新一次的读取才会让该部分得到结果;**重试**要求 Host 重新读取,而不是返回其缓存的结果;连接重置时丢弃所有读取结果,因为重启后的 Host 不再知道其图片使用的文档 id,所以每个已显示的部分会重新读取其文档。图片 URL 基于 `document.baseURI` 解析 `api/plugin-market/detail-file`,因此保留部署前缀,并在 Desktop 中使用 `dsh-app://app/` origin;[`MarkdownText`](../ui-primitives/README.zh.md) 对该路由接受此 origin。 + 本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`;发现页传入条目的本地化标题和所选版本。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件市场时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。 ----- @@ -59,6 +63,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- - **没有升级操作**——可用更新只显示为状态。 +- **只支持纯 Markdown**——详细介绍文档省略原始 HTML(包括 HTML 图片和排版),相对链接(包括页内锚点)不可点击。 - **远程图标**——URL 图标由浏览器请求,会向图片主机暴露用户的 IP 地址。Web UI 通过 HTTPS 提供时,浏览器会拦截或升级来自 `http:` 索引的 `http:` 图标,加载失败的图标显示默认插画。 diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css index 6ad459de5d..c60637db49 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css @@ -291,6 +291,14 @@ margin-top: 16px; } +.detailDocument { + margin-top: 16px; +} + +.detailDocument:empty { + display: none; +} + .sectionTitle { margin: 0; font-size: 14px; diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx index 0941d101ba..26fe9bfa9a 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx @@ -11,6 +11,7 @@ import type { DiscoverFace, DiscoverState, ListingFailure, ListingState, SourceF import { displayDeclarations, indexLabel, presentedVersion, sourceFacts, withText, type DiscoverQuery, type DisplayDeclaration, type SourceFact, } from './presentation.ts' +import { PluginDetailSection } from './PluginDetailSection.tsx' import css from './DiscoverSection.module.css' /** Props of the Discover tab. */ @@ -183,11 +184,13 @@ function VersionRow({ entry, version, title, t, language, onInstall }: { ) } -function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onOpenInstalled }: { +function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInstall, onOpenInstalled }: { readonly entry: MarketListingEntry readonly listing: MarketSearchResult readonly t: Translate readonly face: DiscoverActions + /** The detail section's bound hooks. */ + readonly hooks: Pick readonly language: string readonly onBack: () => void readonly onInstall: (version: MarketListingVersion) => void @@ -229,6 +232,19 @@ function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onO

    )} +
    + +
    +
    +

    {t('versionsTitle')}

    +
      + {entry.versions.map(item => ( + + ))} +
    +

    {t('factSource')}

    @@ -259,14 +275,6 @@ function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onO
    {t('factMarket')}
    {face.resolveText(listing.title)}
    -
    -

    {t('versionsTitle')}

    -
      - {entry.versions.map(item => ( - - ))} -
    -
    ) } @@ -386,11 +394,12 @@ function unfiltered(query: DiscoverQuery): boolean { return query.text.trim() === '' && query.category === null && query.labels.length === 0 } -function Browser({ state, listing, t, face, language, onInstall, openPackage }: { +function Browser({ state, listing, t, face, hooks, language, onInstall, openPackage }: { readonly state: DiscoverState readonly listing: ListingState readonly t: Translate readonly face: DiscoverActions + readonly hooks: Pick readonly language: string readonly onInstall: (choice: InstallChoice) => void readonly openPackage: (name: string) => void @@ -413,7 +422,7 @@ function Browser({ state, listing, t, face, language, onInstall, openPackage }: const openEntry = opened === null ? undefined : value?.entries.find(entry => entry.name === opened.name) ?? opened if (openEntry !== undefined && value !== null) { return ( - { setOpened(null) }} + { setOpened(null) }} onInstall={(version) => { onInstall({ entry: openEntry, version }) }} onOpenInstalled={() => { openPackage(openEntry.name) }} /> ) } @@ -580,7 +589,8 @@ export function DiscoverSection(props: DiscoverSectionProps): ReactNode { ) } else { body = ( - { setConfirming(choice) }} openPackage={openPackage} /> ) } diff --git a/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx b/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx new file mode 100644 index 0000000000..a560f1674a --- /dev/null +++ b/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx @@ -0,0 +1,47 @@ +/** A market entry's long description: the Markdown document the Host reads for one listed version. */ +import { useEffect, useMemo, type ReactNode } from 'react' +import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { detailKey, type DetailFace, type DetailFailureKind, type DetailRequest } from './market-store.ts' + +const FAILURE_KEYS = { + 'too-large': 'detailTooLarge', + 'network': 'detailNetwork', + 'denied': 'detailDenied', + 'git-unavailable': 'detailGitUnavailable', + 'invalid': 'detailInvalid', + 'other': 'detailFailed', +} as const satisfies Record + +/** Props of the detail section: the entry version, the dictionary, and the injected detail reads. */ +export type PluginDetailSectionProps = { readonly request: DetailRequest } & PropsLocale<'pluginMarket'> & InjectFace + +/** + * Read and render an entry version's detail document in the active language. Relative images load from the Host beside + * the document. + * @param props - the entry version, the dictionary, and the injected detail reads. + * @returns the section, or null when the version has no document. + */ +export function PluginDetailSection(props: PluginDetailSectionProps): ReactNode { + const { request, t, loadDetail, retryDetail, detailImage } = props + const language = props.useLocale(snapshot => snapshot.active) + const key = detailKey(request, language) + const state = props.useDetails(details => details[key]) + // The key names the request completely; a read the controller forgot, after a connection reset, starts again. + const missing = state === undefined + useEffect(() => { if (missing) loadDetail(request, language) }, [key, missing]) + const labels = useDetailDocumentLabels({ + title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'), + codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('footnotes'), + }) + const detail = state?.status === 'ready' ? state.detail : undefined + const document = detail?.status === 'found' ? detail.document : undefined + const images = useMemo( + () => document === undefined ? undefined : { resolve: value => detailImage(document, value) }, + [document, detailImage], + ) + const shown = detailDocumentState(state?.status === 'error' + ? { status: 'error', message: t(FAILURE_KEYS[state.failure]) } + : state === undefined || state.status === 'loading' ? state : { status: 'ready', markdown: detail?.status === 'found' ? detail.markdown : undefined }) + return { retryDetail(request, language) }} /> +} diff --git a/packages/client/ui-plugin-market/src/client/index.ts b/packages/client/ui-plugin-market/src/client/index.ts index 754267f2a5..9d92d5da7a 100644 --- a/packages/client/ui-plugin-market/src/client/index.ts +++ b/packages/client/ui-plugin-market/src/client/index.ts @@ -1,6 +1,7 @@ /** * Discover, browser half: the Plugins page tab that browses the plugin market indexes the user configures through the - * `pluginMarket` Remote and installs an entry through the page's install dialog. + * `pluginMarket` Remote and installs an entry through the page's install dialog; entry pages show each version's + * detail document. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -28,7 +29,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'pluginMarket' /** Services the section reads. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket'] +export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket', 'remote.pluginRegistryProbe'] /** * Contribute Discover to the Plugins page and keep it current on Host changes. @@ -36,13 +37,16 @@ export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket'] */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-market: dictionaries') - const controller = new DiscoverController(ctx) + const controller = new DiscoverController(ctx, () => document.baseURI) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-market: controller') ctx.effect(() => { const disposers = [ ctx.remote.$on('plugin-market/changed', () => { controller.sourcesChanged() }), ctx.remote.$on('plugin-manager/changed', () => { controller.bundlesChanged() }), - ctx.on('connection/reset', () => { controller.sourcesChanged() }), + ctx.on('connection/reset', () => { + controller.sourcesChanged() + controller.detailsReset() + }), ] return () => { for (const dispose of disposers) dispose() } }, 'ui-plugin-market: host invalidations') diff --git a/packages/client/ui-plugin-market/src/client/locales.ts b/packages/client/ui-plugin-market/src/client/locales.ts index 63bc504cea..924ff10b43 100644 --- a/packages/client/ui-plugin-market/src/client/locales.ts +++ b/packages/client/ui-plugin-market/src/client/locales.ts @@ -80,6 +80,15 @@ export const zh = { versionPrerelease: '预发布', confirmTitle: '安装「{name}」?', installTrust: '插件在本机以你的权限运行。请确认你信任它的来源', + detailTitle: '详细介绍', + detailLoading: '正在读取详细介绍', + detailTooLarge: '详细介绍或它所在的仓库超过大小限制,未加载', + detailNetwork: '无法连接到详细介绍的来源', + detailDenied: '无法访问详细介绍所在的仓库,请检查 Git 凭据', + detailGitUnavailable: '详细介绍所在的仓库需要用 Git 读取,但无法运行 Git。请安装 Git 后重试', + detailInvalid: '插件声明的详细介绍文件无效', + detailFailed: '无法读取详细介绍', + footnotes: '脚注', } as const /** Dictionary keys owned by this plugin. */ @@ -165,4 +174,13 @@ export const en = { versionPrerelease: 'Prerelease', confirmTitle: 'Install "{name}"?', installTrust: 'Plugins run on this machine with your permissions. Make sure you trust where this one comes from.', + detailTitle: 'Details', + detailLoading: 'Reading details', + detailTooLarge: 'The details or their repository exceed the size limit and were not loaded.', + detailNetwork: 'Could not reach the source of the details.', + detailDenied: 'Could not access the repository of the details. Check your Git credentials.', + detailGitUnavailable: 'The details are in a repository that must be read with Git, and Git could not run. Install Git, then retry.', + detailInvalid: 'The plugin declares an invalid details file.', + detailFailed: 'Could not read the details.', + footnotes: 'Footnotes', } satisfies Record diff --git a/packages/client/ui-plugin-market/src/client/market-store.ts b/packages/client/ui-plugin-market/src/client/market-store.ts index 110acc4773..43d5d2b721 100644 --- a/packages/client/ui-plugin-market/src/client/market-store.ts +++ b/packages/client/ui-plugin-market/src/client/market-store.ts @@ -1,9 +1,10 @@ /** Discover state: configured index sources, the selected index's query and loaded results, and source editing. */ +import { NPMMIRROR_REGISTRY } from '@deepseek-ai/dsh-plugin-manager/registry' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MarketIndexSummary, MarketSearchResult, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { MarketIndexSummary, MarketSearchResult, PluginDetail, PluginDetailDocumentId, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { declaredQuery, INITIAL_QUERY, type DiscoverQuery } from './presentation.ts' @@ -48,13 +49,61 @@ export interface DiscoverState { readonly sourceFailure: SourceFailure | null } -/** What the Discover tab receives through injection. */ -export interface DiscoverFace { +/** The market entry version a detail read is about. */ +export interface DetailRequest { + readonly url: string + readonly name: string + readonly version: string + /** Whether the version is an npm package; the Host reads those of the public registries from the npm file CDNs. */ + readonly npm: boolean +} + +/** Why a detail read failed: the Host's `plugin-market/detail-unavailable` reason, or `other` for any other failure. */ +export type DetailFailureKind = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' | 'other' + +/** One detail read. */ +export type DetailState = + | { readonly status: 'loading' } + | { readonly status: 'ready'; readonly detail: PluginDetail } + | { readonly status: 'error'; readonly failure: DetailFailureKind } + +/** Detail reads by {@link detailKey}. */ +export type DetailsState = Readonly> + +/** + * The identity of a detail read. + * @param request - the plugin. + * @param language - the display language. + * @returns the key of the read in {@link DetailsState}. + */ +export function detailKey(request: DetailRequest, language: string): string { + return JSON.stringify([language, request]) +} + +/** What the entry detail section receives through injection. */ +export interface DetailFace { readonly hooks: { + /** Detail reads, bound as `useDetails`. */ + readonly details: SnapshotStore + /** The active locale, bound as `useLocale`. */ + readonly locale: HostObservable + } + /** Read a plugin's detail document unless the read already ran or runs. */ + readonly loadDetail: (request: DetailRequest, language: string) => void + /** Read a failed detail document again, bypassing the Host's cached result. */ + readonly retryDetail: (request: DetailRequest, language: string) => void + /** + * The URL of an image beside a detail document. + * @returns the authenticated Host URL, or undefined for an absolute, protocol-relative, or fragment destination. + */ + readonly detailImage: (document: PluginDetailDocumentId, destination: string) => string | undefined +} + +/** What the Discover tab receives through injection. */ +export interface DiscoverFace extends DetailFace { + readonly hooks: DetailFace['hooks'] & { /** Discover state, bound as `useDiscover`. */ readonly discover: SnapshotStore - /** The active locale, bound as `useLocale`, for collation. */ - readonly locale: HostObservable } /** Resolve market text in the current language. */ readonly resolveText: (text: LocalizedText) => string @@ -90,6 +139,13 @@ function sourceFailure(failure: RemoteFailure): SourceFailure { return { kind: 'other', reason: failure.message } } +function detailFailure(failure: RemoteFailure): DetailFailureKind { + return failure.code === 'plugin-market/detail-unavailable' ? failure.details.reason : 'other' +} + +/** The Host route of images beside detail documents, relative to the application base. */ +const DETAIL_FILE_ROUTE = 'api/plugin-market/detail-file' + function listingFailure(failure: RemoteFailure): ListingFailure { return failure.code === 'plugin-market/invalid-index' ? 'invalid' : 'fetch' } @@ -98,7 +154,13 @@ function listingFailure(failure: RemoteFailure): ListingFailure { export class DiscoverController { /** Shared Discover state published to the tab. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) + /** Detail reads published to detail sections. */ + readonly details: SnapshotStore = createSnapshotStore({}) private disposed = false + /** The latest detail read of each key; an older read's answer is dropped. */ + private readonly detailReads = new Map() + /** Numbers detail reads across the controller's lifetime, so a read started before a reset never matches one after it. */ + private detailSequence = 0 private indexRead = 0 private listingRead = 0 /** Select the last source once the list it was added to arrives. */ @@ -106,8 +168,11 @@ export class DiscoverController { /** Runs the listing read that failed last. */ private failed: () => void = () => {} - /** @param ctx - the plugin context whose `remote.pluginMarket` namespace answers. */ - constructor(private readonly ctx: ClientContext) {} + /** + * @param ctx - the plugin context whose `remote.pluginMarket` namespace answers. + * @param base - the application base URL that Host routes resolve against. + */ + constructor(private readonly ctx: ClientContext, private readonly base: () => string) {} /** Stop publishing late settlements. */ dispose(): void { @@ -120,8 +185,10 @@ export class DiscoverController { * @returns the tab's injected state sources and actions. */ face(resolveText: DiscoverFace['resolveText']): DiscoverFace { + const detail = this.detailFace() return { - hooks: { discover: this.store, locale: this.ctx.locale }, + ...detail, + hooks: { ...detail.hooks, discover: this.store }, resolveText, ensure: () => { if (this.state().status === 'idle') void this.loadIndexes() }, reload: () => { void this.loadIndexes() }, @@ -152,6 +219,30 @@ export class DiscoverController { } } + /** Detail reads and image addressing of the entry detail section. */ + private detailFace(): DetailFace { + return { + hooks: { details: this.details, locale: this.ctx.locale }, + loadDetail: (request, language) => { + if (this.details.getSnapshot()[detailKey(request, language)] === undefined) void this.readDetail(request, language) + }, + retryDetail: (request, language) => { void this.readDetail(request, language, true) }, + detailImage: (document, destination) => { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(destination)) return undefined + return new URL(`${DETAIL_FILE_ROUTE}?document=${encodeURIComponent(document)}&path=${encodeURIComponent(destination)}`, this.base()).href + }, + } + } + + /** + * Forget every detail read after the connection reset: a restarted Host no longer knows the document ids that address + * their images, and each shown section reads its document again. + */ + detailsReset(): void { + this.detailReads.clear() + this.details.set({}) + } + /** Re-read the sources after the Host announced a change or the connection reset; an unrendered tab stays idle. */ sourcesChanged(): void { if (this.state().status !== 'idle') void this.loadIndexes() @@ -164,6 +255,32 @@ export class DiscoverController { if (listing?.status === 'ready') void this.load(listing.url, listing.query, Math.max((listing.value as MarketSearchResult).entries.length, PAGE_SIZE), false) } + private async readDetail(request: DetailRequest, language: string, refresh = false): Promise { + const key = detailKey(request, language) + const read = ++this.detailSequence + this.detailReads.set(key, read) + this.publishDetail(key, { status: 'loading' }) + // The remote is captured before any wait: the plugin's context may be disposed while the comparison runs. + const { remote } = this.ctx + const current = (): boolean => !this.disposed && this.detailReads.get(key) === read + // Only an npm version waits for the registry comparison; a Git version is read at once. + // npmmirror is asked first where its registry answered the Host's comparison fastest. + let npmFileSources: 'npmmirror'[] = [] + if (request.npm) { + const fastest = await remote.pluginRegistryProbe.fastest() + // A retry or reset started while the comparison ran settles the key instead. + if (!current()) return + if (fastest.ok && fastest.value === NPMMIRROR_REGISTRY) npmFileSources = ['npmmirror'] + } + const { url, name, version } = request + const answer = await remote.pluginMarket.readEntryDetail(url, name, version, language, { refresh, npmFileSources }) + if (current()) this.publishDetail(key, answer.ok ? { status: 'ready', detail: answer.value } : { status: 'error', failure: detailFailure(answer.error) }) + } + + private publishDetail(key: string, state: DetailState): void { + this.details.set({ ...this.details.getSnapshot(), [key]: state }) + } + private state(): DiscoverState { return this.store.getSnapshot() } diff --git a/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts b/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts index 760009b0d9..f17e52fabe 100644 --- a/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts @@ -6,6 +6,8 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { expect, it, onTestFinished, vi } from 'vitest' import { apply, inject } from '../src/client/index.ts' +import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client' +import { brandString } from '@deepseek-ai/dsh-brand' import type { DiscoverFace } from '../src/client/market-store.ts' it('contributes Discover to the Plugins page, follows Host changes, and withdraws with its fiber', async () => { @@ -18,7 +20,7 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw read: vi.fn(), setIndexes: vi.fn(), } - const remote = new TestRemote(ctx, { pluginMarket: market }) + const remote = new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: { fastest: vi.fn() } }) const slots = ctx.get('slots') as SlotRegistry slots.register({ name: 'root', children: { 'plugins.tab': { kind: 'list', scope: 'root' } } } as never, () => null) const fiber = await ctx.plugin({ inject: [...inject], apply }).await() @@ -32,6 +34,8 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw const injected: object = entry!.inject!() const face = injected as DiscoverFace expect(face.resolveText({ en: 'English' })).toBe('English') + expect(face.detailImage(brandString('d'), 'img/a b.png')) + .toBe(new URL('api/plugin-market/detail-file?document=d&path=img%2Fa%20b.png', document.baseURI).href) face.ensure() await vi.waitFor(() => { expect(face.hooks.discover.getSnapshot().status).toBe('ready') }) remote.emit('plugin-market/changed', []) diff --git a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx index f0e4796ea0..88ee3e6cd3 100644 --- a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx +++ b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx @@ -6,7 +6,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { DiscoverSection, type DiscoverSectionProps } from '../src/client/DiscoverSection.tsx' -import type { DiscoverState, ListingState } from '../src/client/market-store.ts' +import type { DetailsState, DiscoverState, ListingState } from '../src/client/market-store.ts' import { INITIAL_QUERY, type DiscoverQuery } from '../src/client/presentation.ts' import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts' @@ -86,6 +86,7 @@ function renderTab(state: Partial = {}, dictionary: Record undefined), } const install = vi.fn() const openPackage = vi.fn() @@ -94,6 +95,7 @@ function renderTab(state: Partial = {}, dictionary: Record) => typeof text === 'string' ? text : text[language] ?? text.en, useDiscover: bindSnapshotSelector(store), useLocale: bindSnapshotSelector(locale), + useDetails: bindSnapshotSelector(createSnapshotStore({})), } as DiscoverSectionProps render() const set = (next: Partial): void => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } @@ -278,9 +280,11 @@ describe('DiscoverSection', () => { }) it('opens an entry page with its source facts and actions, and returns to the list', () => { - const { install, openPackage } = renderTab() + const { install, openPackage, actions } = renderTab() fireEvent.click(card('dsh-a').getByRole('button', { name: 'View Alpha' })) const detail = within(document.querySelector('[data-market-detail="dsh-a"]') as HTMLElement) + expect(actions.loadDetail).toHaveBeenCalledWith({ url: A, name: 'dsh-a', version: '1.0.0', npm: true }, 'en') + expect(detail.getByRole('status', { name: en.detailLoading })).toBeTruthy() expect(detail.getByRole('heading', { name: 'Alpha' })).toBeTruthy() expect(detail.getByText('dsh-a')).toBeTruthy() for (const value of ['Jane', 'dsh-a@1.0.0', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha market']) expect(detail.getByText(value)).toBeTruthy() @@ -297,6 +301,7 @@ describe('DiscoverSection', () => { fireEvent.click(detail.getByRole('button', { name: en.backToList })) fireEvent.click(card('dsh-b').getByRole('button', { name: 'View dsh-b' })) const git = within(document.querySelector('[data-market-detail="dsh-b"]') as HTMLElement) + expect(actions.loadDetail).toHaveBeenLastCalledWith({ url: A, name: 'dsh-b', version: '1.0.0', npm: false }, 'en') expect(git.queryByRole('link')).toBeNull() for (const value of ['https://github.com/o/r', COMMIT, 'packages/b', 'Themes']) expect(git.getByText(value)).toBeTruthy() fireEvent.click(git.getByRole('button', { name: 'View dsh-b in Installed' })) diff --git a/packages/client/ui-plugin-market/tests/market-store.client.spec.ts b/packages/client/ui-plugin-market/tests/market-store.client.spec.ts index 203fca4c37..4bb0646530 100644 --- a/packages/client/ui-plugin-market/tests/market-store.client.spec.ts +++ b/packages/client/ui-plugin-market/tests/market-store.client.spec.ts @@ -1,9 +1,12 @@ import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { + MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult, PluginDetail, PluginDetailDocumentId, +} from '@deepseek-ai/dsh-api-remotes/client' +import { brandString } from '@deepseek-ai/dsh-brand' import { describe, expect, it, onTestFinished, vi } from 'vitest' -import { DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts' +import { detailKey, DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts' import { INITIAL_QUERY } from '../src/client/presentation.ts' const A = 'https://a.example/index.json' @@ -45,11 +48,15 @@ function setup(indexes: MarketIndexSummary[] = [{ url: A }, { url: B }]) { listIndexes: vi.fn(() => ok(state.indexes)), search: vi.fn((url: string, _query: MarketQuery) => ok(listing(url, url === A ? 'A' : 'B', url === A ? 'Desc' : undefined))), setIndexes: vi.fn((urls: string[]) => { state.indexes = urls.map(url => ({ url: url.trim() })); return ok(undefined) }), + readEntryDetail: vi.fn( + (_url: string, _name: string, _version: string, _language: string, _options: object): Promise> => ok({ status: 'none' }), + ), } - new TestRemote(ctx, { pluginMarket: market }) - const controller = new DiscoverController(ctx) + const probe = { fastest: vi.fn((): Promise> => ok('https://registry.npmmirror.com/')) } + new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: probe }) + const controller = new DiscoverController(ctx, () => 'https://dsh.example/prefix/') const face = controller.face(text => typeof text === 'string' ? text : text.en) - return { controller, face, market, state, snapshot: () => controller.store.getSnapshot() } + return { controller, face, market, probe, state, snapshot: () => controller.store.getSnapshot() } } describe('DiscoverController', () => { @@ -343,3 +350,127 @@ describe('DiscoverController', () => { expect(snapshot().listing?.status).toBe('more') }) }) + +describe('detail reads', () => { + const BUNDLE = { url: A, name: 'b', version: '1.0.0', npm: true } as const + const ENTRY = { url: B, name: 'e', version: '2.0.0', npm: false } as const + const FOUND: PluginDetail = { status: 'found', origin: 'readme', markdown: '# B', document: brandString('doc') } + + it('reads each plugin once per language and publishes the result', async () => { + const { controller, face, market } = setup() + market.readEntryDetail.mockImplementationOnce(() => ok(FOUND)) + face.loadDetail(BUNDLE, 'en') + face.loadDetail(BUNDLE, 'en') + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' }) + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) }) + face.loadDetail(ENTRY, 'zh') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + // An npm version asks the registry comparison and reads npmmirror first where it won; a Git version does not ask. + expect(market.readEntryDetail.mock.calls).toEqual([ + [A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: ['npmmirror'] }], [B, 'e', '2.0.0', 'zh', { refresh: false, npmFileSources: [] }], + ]) + }) + + it('reports failures by reason and reads again on retry', async () => { + const { controller, face, market } = setup() + // The Git entry's read starts at once; the npm one waits for the registry comparison, so answers go by name. + market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/unknown-plugin', 'gone', { name: 'e', version: '2.0.0' })) + market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/detail-unavailable', 'big', { reason: 'too-large' })) + face.loadDetail(BUNDLE, 'en') + face.loadDetail(ENTRY, 'en') + await vi.waitFor(() => { + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'error', failure: 'too-large' }) + expect(controller.details.getSnapshot()[detailKey(ENTRY, 'en')]).toEqual({ status: 'error', failure: 'other' }) + }) + face.retryDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + // A retry bypasses the Host's cached result. + expect(market.readEntryDetail.mock.calls.at(-1)).toEqual([A, 'b', '1.0.0', 'en', { refresh: true, npmFileSources: ['npmmirror'] }]) + }) + + it('settles a retried read with the retry\'s answer', async () => { + const { controller, face, market } = setup() + let first: (value: Answer) => void = () => {} + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { first = resolve })) + market.readEntryDetail.mockImplementationOnce(() => ok(FOUND)) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledOnce() }) + face.retryDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) }) + first({ ok: true, value: { status: 'none' } }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) + }) + + it('forgets every read after the connection reset and drops the answers of forgotten reads', async () => { + const { controller, face, market } = setup() + let answer: (value: Answer) => void = () => {} + face.loadDetail(ENTRY, 'zh') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]?.status).toBe('ready') }) + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(2) }) + controller.detailsReset() + expect(controller.details.getSnapshot()).toEqual({}) + answer({ ok: true, value: FOUND }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()).toEqual({}) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') }) + // A read from before a reset that settles after the read that replaced it does not overwrite it. + let stale: (value: Answer) => void = () => {} + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { stale = resolve })) + controller.detailsReset() + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(3) }) + controller.detailsReset() + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + stale({ ok: true, value: FOUND }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) + }) + + it('keeps the configured CDN order where npmmirror did not answer fastest', async () => { + const { controller, face, market, probe } = setup() + probe.fastest.mockImplementationOnce(() => ok('https://registry.npmjs.org/')) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') }) + expect(market.readEntryDetail.mock.calls).toEqual([[A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: [] }]]) + }) + + it('drops a read whose registry comparison settles after a reset or disposal, without asking the Host', async () => { + const { controller, face, market, probe } = setup() + let settle: (value: Answer) => void = () => {} + probe.fastest.mockImplementation(() => new Promise((resolve) => { settle = resolve })) + face.loadDetail(BUNDLE, 'en') + controller.detailsReset() + settle({ ok: true, value: null }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + face.loadDetail(BUNDLE, 'en') + controller.dispose() + settle({ ok: true, value: null }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(market.readEntryDetail).not.toHaveBeenCalled() + }) + + it('stops publishing detail reads after disposal', async () => { + const { controller, face, market } = setup() + let answer: (value: Answer) => void = () => {} + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + face.loadDetail(BUNDLE, 'en') + controller.dispose() + answer({ ok: true, value: FOUND }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' }) + }) + + it('addresses relative images through the Host route and leaves other destinations alone', () => { + const { face } = setup() + const doc = brandString('d 1') + expect(face.detailImage(doc, 'img/a.png')).toBe('https://dsh.example/prefix/api/plugin-market/detail-file?document=d%201&path=img%2Fa.png') + for (const destination of ['https://example.com/a.png', 'data:image/png;base64,AA', '//cdn.example/a.png', '#top', 'mailto:a@b']) { + expect(face.detailImage(doc, destination)).toBeUndefined() + } + }) +}) diff --git a/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx b/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx new file mode 100644 index 0000000000..5380c7b7c9 --- /dev/null +++ b/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client' +import { brandString } from '@deepseek-ai/dsh-brand' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { PluginDetailSection, type PluginDetailSectionProps } from '../src/client/PluginDetailSection.tsx' +import { detailKey, type DetailRequest, type DetailsState } from '../src/client/market-store.ts' +import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +const COMMON: Record = { 'copy': 'Copy', 'copied': 'Copied', 'codeBlock.title': 'Code block', 'codeBlock.wrap': 'Wrap', 'codeBlock.unwrap': 'Unwrap', 'retry': 'Retry' } + +function translator(dictionary: Record): PluginDetailSectionProps['t'] { + const strings: Record = dictionary + return (key: string) => strings[key] ?? COMMON[key] ?? key +} + +const REQUEST: DetailRequest = { url: 'https://a.example/index.json', name: 'a', version: '1.0.0', npm: true } +const DOCUMENT = brandString('doc') + +function setup(initial: DetailsState = {}, language = 'en') { + const details = createSnapshotStore(initial) + const locale = createSnapshotStore({ active: language, locales: [], revision: 0 }) + const face = { + loadDetail: vi.fn(), retryDetail: vi.fn(), + detailImage: vi.fn((document: PluginDetailDocumentId, destination: string) => `https://dsh.example/api/plugin-market/detail-file?document=${document}&path=${destination}`), + useDetails: bindSnapshotSelector(details), useLocale: bindSnapshotSelector(locale), + } + const set = (key: string, state: DetailsState[string]): void => { act(() => { details.set({ ...details.getSnapshot(), [key]: state }) }) } + const forget = (): void => { details.set({}) } + return { face, set, locale, forget } +} + +it('reads the document of the request in the active language and renders its Markdown with images beside it', () => { + const { face, set, locale, forget } = setup() + const view = render() + expect(face.loadDetail).toHaveBeenCalledWith(REQUEST, 'en') + expect(screen.getByRole('status', { name: en.detailLoading })).toBeTruthy() + set(detailKey(REQUEST, 'en'), { status: 'ready', detail: { status: 'found', origin: 'readme', markdown: '# Alpha\n\n![shot](img/shot.png) [guide](docs/guide.md)', document: DOCUMENT } }) + expect(screen.queryByRole('heading', { name: en.detailTitle })).toBeNull() + expect(screen.getByRole('heading', { name: 'Alpha' })).toBeTruthy() + expect(screen.getByRole('img', { name: 'shot' }).getAttribute('src')).toBe('https://dsh.example/api/plugin-market/detail-file?document=doc&path=img/shot.png') + // Relative links stay text. + expect(screen.queryByRole('link', { name: 'guide' })).toBeNull() + expect(screen.getByText('guide')).toBeTruthy() + act(() => { locale.set({ active: 'zh', locales: [], revision: 1 }) }) + expect(face.loadDetail).toHaveBeenLastCalledWith(REQUEST, 'zh') + view.rerender() + expect(face.loadDetail).toHaveBeenCalledTimes(2) + // A read the controller forgot starts again; a settled one does not. + set(detailKey(REQUEST, 'zh'), { status: 'ready', detail: { status: 'none' } }) + act(() => { forget() }) + expect(face.loadDetail).toHaveBeenCalledTimes(3) +}) + +it('renders nothing without a document, and a reason with a retry when the read failed', () => { + const key = detailKey(REQUEST, 'en') + const { face, set } = setup({ [key]: { status: 'ready', detail: { status: 'none' } } }) + const view = render() + expect(view.container.innerHTML).toBe('') + const reasons = [ + ['too-large', en.detailTooLarge], ['network', en.detailNetwork], ['denied', en.detailDenied], + ['invalid', en.detailInvalid], ['other', en.detailFailed], + ] as const + for (const [failure, text] of reasons) { + set(key, { status: 'error', failure }) + expect(screen.getByRole('alert').textContent).toBe(text) + } + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(face.retryDetail).toHaveBeenCalledWith(REQUEST, 'en') +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2e59aaf715..92dcd1dcd0 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,14 +18,14 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 6f5457b84fc9db09 - zh: 85993f1c44bfee2c + en: 03388f40dc593990 + zh: 5f1541a6e20c8cfe /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 28ac8f0b1edbcf81 zh: 5c4c324bf84a282f /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 12fe9d9b1410edef - zh: f2dfdc01170d7cce + en: 4b6e7c1dd8d8c2a7 + zh: 227747b459518e37 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 4aef65dea11de083 zh: abc22cd7c65cdf38 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5b29187be1..803cce60c0 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -66,6 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | +| `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. | | `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. | | `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | @@ -99,7 +100,7 @@ The catalog above lists what each export is for; this section covers the behavio The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file` route; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c1593e273b..a87598a306 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -66,6 +66,7 @@ kind: "package-library" | `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | +| `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 | | `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 | | `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | @@ -99,7 +100,7 @@ kind: "package-library" 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 diff --git a/packages/client/ui-primitives/src/DetailDocument.module.css b/packages/client/ui-primitives/src/DetailDocument.module.css new file mode 100644 index 0000000000..6e75b56606 --- /dev/null +++ b/packages/client/ui-primitives/src/DetailDocument.module.css @@ -0,0 +1,65 @@ +.section { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.document { + min-width: 0; + /* A long README scrolls inside the section instead of pushing the page's later sections out of reach. */ + max-height: min(560px, 60vh); + overflow-y: auto; + padding: 4px 16px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + overflow-wrap: anywhere; +} + +.document:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 3px; +} + +.failure { + display: flex; + align-items: center; + gap: 10px; +} + +.error { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-state-error-primary); +} + +.skeleton { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bar { + display: block; + height: 12px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-bg-skeleton); + animation: dsh-plugin-detail-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +.long { width: min(480px, 90%); } +.short { width: min(240px, 50%); } + +@keyframes dsh-plugin-detail-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .bar { animation: none; } +} diff --git a/packages/client/ui-primitives/src/DetailDocument.tsx b/packages/client/ui-primitives/src/DetailDocument.tsx new file mode 100644 index 0000000000..d965fce410 --- /dev/null +++ b/packages/client/ui-primitives/src/DetailDocument.tsx @@ -0,0 +1,120 @@ +import { useMemo, type ReactNode } from 'react' +import { Button } from './Button.tsx' +import { MarkdownText, type MarkdownLabels, type MarkdownPathImages } from './markdown/MarkdownText.tsx' +import { StateDot } from './StateDot.tsx' +import css from './DetailDocument.module.css' + +/** One read of a long Markdown description, such as a plugin's detail document. */ +export type DetailDocumentState = + | { readonly status: 'loading' } + | { readonly status: 'found'; readonly markdown: string } + | { readonly status: 'none' } + | { readonly status: 'error'; readonly message: string } + +/** A read as owners track it: in flight, settled with a document or none, or failed with a localized message. */ +export type DetailDocumentRead = + | undefined + | { readonly status: 'loading' } + | { readonly status: 'ready'; readonly markdown: string | undefined } + | { readonly status: 'error'; readonly message: string } + +/** + * The state {@link DetailDocument} shows for an owner's read: loading until it settles, then the document, none, or the + * failure. + * @param read - the owner's read; undefined before it starts. + * @returns the state to show. + */ +export function detailDocumentState(read: DetailDocumentRead): DetailDocumentState { + if (read?.status === 'ready') return read.markdown === undefined ? { status: 'none' } : { status: 'found', markdown: read.markdown } + return read?.status === 'error' ? read : { status: 'loading' } +} + +/** Translated strings of a detail document's chrome, as an owner's dictionary supplies them. */ +export interface DetailDocumentCopy { + readonly title: string + readonly loading: string + readonly retry: string + readonly copy: string + readonly copied: string + readonly codeBlock: string + readonly wrap: string + readonly unwrap: string + readonly footnotes: string +} + +/** + * Assemble reference-stable labels for {@link DetailDocument} from translated strings. + * @param copy - the translated strings. + * @returns labels that keep their identity while the strings stay equal. + */ +export function useDetailDocumentLabels(copy: DetailDocumentCopy): DetailDocumentLabels { + const { title, loading, retry, footnotes } = copy + const { copy: copyLabel, copied: copiedLabel, codeBlock: codeLabel, wrap: wrapLabel, unwrap: unwrapLabel } = copy + const markdown = useMemo(() => ({ code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes }), + [copyLabel, copiedLabel, codeLabel, wrapLabel, unwrapLabel, footnotes]) + return useMemo(() => ({ title, loading, retry, markdown }), [title, loading, retry, markdown]) +} + +/** Localized chrome of a detail document. */ +export interface DetailDocumentLabels { + /** Accessible name of the scrollable document region; the section shows no heading of its own. */ + readonly title: string + /** Accessible name of the loading placeholder. */ + readonly loading: string + /** Retry button text. */ + readonly retry: string + readonly markdown: MarkdownLabels +} + +/** + * A section showing a long Markdown description, without a heading of its own: three skeleton lines while it loads, the + * document once found, or the owner's failure message with a retry. Nothing renders for `none`. A document taller than + * the section's height limit scrolls inside it, and the scroll region, named by `labels.title`, takes keyboard focus. + * Raw HTML is omitted, relative links stay text, and only images the owner's `images` resolver accepts, or absolute + * HTTP(S) images, load. + * @param props.state - the read to show. + * @param props.labels - localized region name, placeholder name, retry text, and Markdown chrome; keep `markdown` reference-stable. + * @param props.images - resolves the document's relative image destinations to displayable URLs. + * @param props.onRetry - reads the document again after a failure. + * @returns the section, or null. + */ +export function DetailDocument({ state, labels, images, onRetry }: { + readonly state: DetailDocumentState + readonly labels: DetailDocumentLabels + readonly images?: MarkdownPathImages | undefined + readonly onRetry: () => void +}): ReactNode { + if (state.status === 'none') return null + let body: ReactNode + switch (state.status) { + case 'found': + body = ( +
    + +
    + ) + break + case 'error': + body = ( +
    +

    {state.message}

    + +
    + ) + break + case 'loading': + body = ( +
    + + + +
    + ) + break + } + return ( +
    + {body} +
    + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 18f9544405..1083cf6827 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -106,6 +106,8 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx' export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx' +export { DetailDocument, detailDocumentState, useDetailDocumentLabels } from './DetailDocument.tsx' +export type { DetailDocumentCopy, DetailDocumentLabels, DetailDocumentRead, DetailDocumentState } from './DetailDocument.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index ab2c0b380a..c76ed4171f 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -32,6 +32,7 @@ function renderSettled( labels: MarkdownLabels, fileMentions: MarkdownFileMentions | undefined, pathImages: MarkdownPathImages | undefined, + omitHtml: boolean, ): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() @@ -41,6 +42,7 @@ function renderSettled( labels, fileMentions, pathImages, + omitHtml, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -74,8 +76,11 @@ class StreamingRenderer { private lastText: string | null = null private lastRendered: ReactNode[] = [] - /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */ - constructor(private readonly labels: MarkdownLabels) {} + /** + * @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. + * @param omitHtml - Whether raw HTML renders nothing; the owner replaces the renderer when it changes. + */ + constructor(private readonly labels: MarkdownLabels, readonly omitHtml: boolean) {} /** * Render the current accumulated text. Idempotent per text value, so React @@ -110,6 +115,7 @@ class StreamingRenderer { labels: this.labels, fileMentions: undefined, pathImages: undefined, + omitHtml: this.omitHtml, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, footnoteCounts: this.frozenFootnoteCounts, @@ -129,6 +135,7 @@ class StreamingRenderer { labels: this.labels, fileMentions: undefined, pathImages: undefined, + omitHtml: this.omitHtml, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], footnoteCounts: new Map(this.frozenFootnoteCounts), @@ -164,15 +171,17 @@ class StreamingRenderer { * `MarkdownDelegateProvider` can delegate ordinary HTTP(S) activation while * modified clicks retain native behavior. `variant="compact"` uses secondary * text sizing, uniform bold headings, and tight block spacing; the default - * `body` variant uses the full document typography. + * `body` variant uses the full document typography. `omitHtml` renders raw + * HTML as nothing instead of literal text, for documents written for HTML + * renderers, such as package READMEs. * The provider's `openFile` enables local Markdown links in settled messages, * including `#L24` and `#L24-L30` destinations (ranges open at their first line). - * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and - * unsafe protocols are disabled. Local links without an opener remain text; + * @returns A GFM document with TeX math rendered through KaTeX; raw HTML never + * renders as markup, and unsafe protocols are disabled. Local links without an opener remain text; * absolute HTTP(S) images render directly. */ export const MarkdownText = memo(function MarkdownText({ - text, streaming = false, labels, fileMentions, pathImages, variant = 'body', + text, streaming = false, labels, fileMentions, pathImages, variant = 'body', omitHtml = false, }: { text: string streaming?: boolean @@ -180,20 +189,21 @@ export const MarkdownText = memo(function MarkdownText({ fileMentions?: MarkdownFileMentions | undefined pathImages?: MarkdownPathImages | undefined variant?: 'body' | 'compact' + omitHtml?: boolean }) { const streamRef = useRef(null) const streamLabelsRef = useRef(labels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, labels, fileMentions, pathImages) + return renderSettled(text, labels, fileMentions, pathImages, omitHtml) } - if (streamRef.current === null || streamLabelsRef.current !== labels) { - streamRef.current = new StreamingRenderer(labels) + if (streamRef.current === null || streamLabelsRef.current !== labels || streamRef.current.omitHtml !== omitHtml) { + streamRef.current = new StreamingRenderer(labels, omitHtml) streamLabelsRef.current = labels } return streamRef.current.render(text) - }, [text, streaming, labels, fileMentions, pathImages]) + }, [text, streaming, labels, fileMentions, pathImages, omitHtml]) return
    {children}
    }) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index cdb9afcbda..c4ff4a2400 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -77,12 +77,17 @@ function remoteImageUrl(url: string): string | undefined { } } -/** Rewritten images may use Web media protocols or the Desktop application's file route. */ +/** Authenticated Host media routes as the Desktop application addresses them. */ +const DESKTOP_MEDIA_ROUTES = [ + 'dsh-app://app/api/file?', 'dsh-app://app/api/plugin-market/detail-file?', 'dsh-app://app/api/plugin-manager/detail-file?', +] + +/** Rewritten images may use Web media protocols or the Desktop application's media routes. */ function vocabularyImageUrl(url: string): string | undefined { try { const protocol = new URL(url).protocol return protocol === 'http:' || protocol === 'https:' || protocol === 'blob:' || protocol === 'data:' - || url.startsWith('dsh-app://app/api/file?') + || DESKTOP_MEDIA_ROUTES.some(route => url.startsWith(route)) ? url : undefined } catch { @@ -193,6 +198,8 @@ export interface MarkdownRenderContext { readonly fileMentions: MarkdownFileMentions | undefined /** Local-path image vocabulary; absent wherever no rewriting owner exists. */ readonly pathImages: MarkdownPathImages | undefined + /** Raw HTML renders nothing instead of literal text. */ + readonly omitHtml: boolean /** Inside an anchor's children: interactive mentions must not nest there. */ readonly inLink?: boolean /** Reference targets visible to this pass. */ @@ -331,8 +338,8 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte return {value} } case 'html': - // No HTML parser enters the pipeline: raw HTML stays literal text. - return node.value + // No HTML parser enters the pipeline: raw HTML is literal text, or nothing where the owner omits it. + return context.omitHtml ? null : node.value case 'code': return renderCode(node, key, context) case 'math': diff --git a/packages/client/ui-primitives/tests/detail-document.client.spec.tsx b/packages/client/ui-primitives/tests/detail-document.client.spec.tsx new file mode 100644 index 0000000000..0e2ae34095 --- /dev/null +++ b/packages/client/ui-primitives/tests/detail-document.client.spec.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type DetailDocumentCopy, type DetailDocumentLabels } from '../src/DetailDocument.tsx' + +afterEach(cleanup) + +const LABELS: DetailDocumentLabels = { + title: 'Details', loading: 'Reading details', retry: 'Retry', + markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, footnotes: 'Footnotes' }, +} + +it('shows a placeholder, the document with resolved images, or a failure with a retry, and nothing without a document', () => { + const onRetry = vi.fn() + const view = render() + // The section has no heading of its own; its title names the document region. + expect(screen.queryByRole('heading', { name: 'Details' })).toBeNull() + expect(screen.getByRole('status', { name: 'Reading details' })).toBeTruthy() + view.rerender(bold
    kept\n\n
    ' }} labels={LABELS} + images={{ resolve: value => `https://host.example/${value}` }} onRetry={onRetry} />) + expect(screen.getByRole('heading', { name: 'A' })).toBeTruthy() + // The document scrolls inside a keyboard-focusable region named by the section heading. + const region = screen.getByRole('region', { name: 'Details' }) + expect(region.tabIndex).toBe(0) + expect(region.contains(screen.getByRole('heading', { name: 'A' }))).toBe(true) + expect(screen.getByRole('img', { name: 'i' }).getAttribute('src')).toBe('https://host.example/a.png') + expect(screen.queryByRole('link', { name: 'doc' })).toBeNull() + // Raw HTML renders nothing: neither markup nor its literal text. + expect(view.container.textContent).not.toContain('<') + expect(view.container.textContent).toContain('bold kept') + expect(view.container.querySelector('div[align]')).toBeNull() + view.rerender() + expect(screen.getByRole('alert').textContent).toBe('Could not read') + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRetry).toHaveBeenCalledOnce() + view.rerender() + expect(view.container.innerHTML).toBe('') +}) + +it('maps owner reads to shown states', () => { + expect(detailDocumentState(undefined)).toEqual({ status: 'loading' }) + expect(detailDocumentState({ status: 'loading' })).toEqual({ status: 'loading' }) + expect(detailDocumentState({ status: 'ready', markdown: '# A' })).toEqual({ status: 'found', markdown: '# A' }) + expect(detailDocumentState({ status: 'ready', markdown: undefined })).toEqual({ status: 'none' }) + expect(detailDocumentState({ status: 'error', message: 'm' })).toEqual({ status: 'error', message: 'm' }) +}) + +it('keeps label identity while the translated strings stay equal', () => { + const copy: DetailDocumentCopy = { + title: 'Details', loading: 'Reading', retry: 'Retry', copy: 'Copy', copied: 'Copied', codeBlock: 'Code', wrap: 'Wrap', unwrap: 'Unwrap', footnotes: 'Notes', + } + const { result, rerender } = renderHook((props: DetailDocumentCopy) => useDetailDocumentLabels(props), { initialProps: copy }) + const first = result.current + expect(first).toEqual({ + title: 'Details', loading: 'Reading', retry: 'Retry', + markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels: { codeLabel: 'Code', wrapLabel: 'Wrap', unwrapLabel: 'Unwrap' } }, footnotes: 'Notes' }, + }) + rerender({ ...copy }) + expect(result.current).toBe(first) + rerender({ ...copy, title: 'Detail' }) + expect(result.current.markdown).toBe(first.markdown) +}) diff --git a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx index 85516cfd0a..11aa179572 100644 --- a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx @@ -74,14 +74,21 @@ describe('MarkdownText local-path images', () => { } }) - it('accepts only the Desktop file route from a local image resolver', () => { + it('accepts only the Desktop media routes from a local image resolver', () => { const src = 'dsh-app://app/api/file?path=%2Ftmp%2Fgraph.png' const view = render( src }} />) expect(view.container.querySelector('img')).toBeNull() view.rerender( src }} />) expect(view.getByRole('img').getAttribute('src')).toBe(src) + const detail = 'dsh-app://app/api/plugin-market/detail-file?document=d&path=a.png' + const bundle = 'dsh-app://app/api/plugin-manager/detail-file?bundle=b&document=README.md&path=a.png' + for (const route of [detail, bundle]) { + view.rerender( route }} />) + expect(view.getByRole('img').getAttribute('src')).toBe(route) + } for (const result of ['dsh-app://shell/api/file?path=x', 'dsh-app://app/assets/image.png', - 'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x']) { + 'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x', 'dsh-app://app/api/plugin-market/other?path=x', + 'dsh-app://app/api/plugin-manager/other?path=x']) { view.rerender( result }} />) expect(view.container.querySelector('img')).toBeNull() } diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx index bf1877c348..3c4536b7e7 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx @@ -23,6 +23,7 @@ function makeContext(): MarkdownRenderContext { labels: markdownLabels, fileMentions: undefined, pathImages: undefined, + omitHtml: false, targets: createReferenceTargets(), footnoteOrder: [], footnoteCounts: new Map(), diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index c655a007d2..553f60233a 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -332,6 +332,17 @@ describe('MarkdownText', () => { } }) + it('omits raw HTML when asked, in settled and streaming renders, and rebuilds the stream when the choice changes', () => { + const text = 'Before bold after\n\n
    block
    \n\nTail' + const view = render() + expect(view.container.textContent).toBe('Before bold after\nTail') + expect(view.container.querySelector('div[align]')).toBeNull() + view.rerender() + expect(view.container.textContent).toBe('Before bold after\nTail') + view.rerender() + expect(view.container.textContent).toContain('
    block
    ') + }) + it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => { const markdown = [ '', diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index a458bb8cf0..a265b7029e 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -15,8 +15,8 @@ en: 2843d46e95311bce zh: ec11bdeb5d3ce818 /deepseek-ai-dsh-client-ui-settings-general/use-this-package: - en: 2bc21e57fe17eab9 - zh: e441cd313f747b22 + en: 4285513efdb1be68 + zh: b28089509254e8d5 /deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section: en: 8b8a7c6287bb303b zh: 19535112531aedd5 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index b005515ea4..cf0c202f62 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -35,7 +35,7 @@ When the page reconnects to a Host running another release, a **Service version When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed. -In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs. +In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs, including guidance to exit fullscreen and retry after a fullscreen-transition timeout. Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (Desktop: `Mod+,`; Windows/macOS Web: `Mod+Alt+,`) toggles the dialog when Settings is in front or no modal is open; the sidebar control opens the same dialog. The command cannot open or close Settings while the shortcut reference or another modal is in front. Held-key repeats do nothing. Initial focus lands on the selected section in the navigation, or on the title when no sections are available, without drawing a focus outline. Tab and directional navigation retain their visible focus indicators. `Mod+/` can open the shortcut reference above Settings; `Escape` closes the top dialog and restores focus to its invoking control. Return focus omits outlines after shortcut, Escape, or pointer dismissal. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 8628916c7b..616bfd3c84 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" 当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。 -在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。 +在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致,包括全屏切换超时后退出全屏并重试的指引。 设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置位于最前方或没有模态弹窗时,设置命令(Desktop 为 `Mod+,`,Windows/macOS Web 为 `Mod+Alt+,`)切换设置弹窗的开关;侧栏控件打开同一弹窗。快捷键速查或其他模态弹窗位于前方时,该命令不能打开或关闭设置。长按产生的重复按键不执行操作。初始焦点落在导航中选中的分区;没有可用分区时落在标题上,均不绘制焦点外轮廓线。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。 diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx index 588b3befdb..728fee6537 100644 --- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -37,6 +37,7 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l 'stop-failed': t('desktop.update.stopFailed'), 'tasks-changed': t('desktop.update.tasksChanged'), 'tasks-unavailable': t('desktop.update.tasksUnavailable'), + 'fullscreen-timeout': t('desktop.update.fullscreenTimeout'), } return { label, detail: failures[state.failure ?? 'install'] } } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index f12d407a74..7d189d496c 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -19,6 +19,7 @@ export const zh = { 'desktop.update.checkNetworkFailed': '检查更新失败,请检查网络连接后重试。', 'desktop.update.downloadNetworkFailed': '下载更新失败,请检查网络连接后重试。', 'desktop.update.installNetworkFailed': '安装更新失败,请检查网络连接后重试。', + 'desktop.update.fullscreenTimeout': '无法退出全屏,请退出全屏后重试更新', 'desktop.update.stopFailed': '未能安全停止任务,更新尚未安装,请稍后重试。', 'desktop.update.tasksChanged': '有新任务开始运行,请重新确认是否停止任务并更新。', 'desktop.update.tasksUnavailable': '无法确认任务状态,请在工作区就绪后重试更新。', @@ -67,6 +68,7 @@ export const en = { 'desktop.update.checkNetworkFailed': 'Could not check for updates. Check your connection and try again.', 'desktop.update.downloadNetworkFailed': 'Could not download the update. Check your connection and try again.', 'desktop.update.installNetworkFailed': 'Could not install the update. Check your connection and try again.', + 'desktop.update.fullscreenTimeout': 'Could not exit fullscreen. Exit fullscreen, then retry the update.', 'desktop.update.stopFailed': 'Could not safely stop the tasks. The update has not been installed. Please try again later.', 'desktop.update.tasksChanged': 'New tasks have started. Confirm again to stop the tasks and update.', 'desktop.update.tasksUnavailable': 'Task status is unavailable. Try updating again when the workspace is ready.', diff --git a/packages/client/ui-settings-general/src/types.ts b/packages/client/ui-settings-general/src/types.ts index f9dae34f87..9aa0ea95e3 100644 --- a/packages/client/ui-settings-general/src/types.ts +++ b/packages/client/ui-settings-general/src/types.ts @@ -11,6 +11,7 @@ export type DesktopUpdateFailureKind = | 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' + | 'fullscreen-timeout' /** Public semantic preload fields consumed by the settings row. */ export interface DesktopUpdatePresentation { diff --git a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx index 505a02d969..bfcebc3c0e 100644 --- a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx @@ -177,3 +177,24 @@ it.each([ expect((await screen.findByRole('tooltip')).textContent).toBe(label) } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) } }) + +it('localizes fullscreen timeout guidance and preserves the retry action', async () => { + const f = fixture() + const copy: string[] = [] + try { + await f.emit({ phase: 'error', failure: 'fullscreen-timeout', version: available.version }) + for (const dictionary of [zh, en]) { + f.view.rerender() + fireEvent.focus(screen.getByRole('button')) + copy.push((await screen.findByRole('tooltip')).textContent ?? '') + } + expect(copy).toMatchInlineSnapshot(` + [ + "无法退出全屏,请退出全屏后重试更新", + "Could not exit fullscreen. Exit fullscreen, then retry the update.", + ] + `) + fireEvent.click(screen.getByRole('button')) + expect(f.open).toHaveBeenCalledOnce() + } finally { f.view.unmount(); f.status.resolve(available) } +}) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 58750ae463..f48e27b281 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 3af73644655c8b7b - zh: d689eba772100c63 + en: 2ab40f99bd12dc17 + zh: 2b5fd79885cb85fb /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: 9d533c65c82cd4a4 zh: 0a5da334e127c035 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 1b07faff50..c913910fe2 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -61,6 +61,8 @@ Expand **Customized settings → Model options** to edit each model. Both provid A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. +Empty pi-ai capacity fields display `1M` for the context window and `128K` for maximum output tokens. These hints use decimal K/M units; leaving the fields empty preserves adapter inheritance. Provider-level defaults can differ from the hints. + ### Adding and deleting providers **Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index f272908232..dee1384e0b 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -61,6 +61,8 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 +pi-ai 容量字段为空时,上下文窗口显示 `1M`,最大输出 token 数显示 `128K`。这些提示使用十进制 K/M 单位;保留空值会继续继承适配器配置。提供方级默认值可能与提示不同。 + ### 新增与删除提供商 **添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index c80f638f7c..4e6633ee5f 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -112,16 +112,13 @@ type CapacityField = 'contextWindow' | 'maxTokens' * What an empty capacity field is worth, shown as its placeholder so a row left * blank does not read as a model with no capacity at all. * - * The magnitudes are the adapter's own route-level fallbacks (`llm-pi-ai`'s - * `defaultContextWindow` and `defaultMaxTokens`), spelled the way a person - * would say them. They are a hint, not a mirror: this page counts `K` as 1000, - * so typing `256K` stores 256000 while leaving the field blank keeps the - * adapter's 262144. A deployment that overrides those defaults is not - * reflected here — nothing on this page can read them. + * These hints match llm-pi-ai's built-in `defaultContextWindow` and + * `defaultMaxTokens` in decimal K/M notation. Provider overrides are not + * available to this component and are not reflected in the hints. */ const CAPACITY_HINT: Readonly> = { - contextWindow: '256K', - maxTokens: '32K', + contextWindow: '1M', + maxTokens: '128K', } /** diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 7a49fa4505..6f80b089b9 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -310,6 +310,8 @@ describe('model list editing', () => { fireEvent.click(screen.getByRole('button', { name: en.addModel })) fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) expandModel(1) + expect(screen.getByLabelText(`${en.contextWindow} 1`).placeholder).toBe('1M') + expect(screen.getByLabelText(`${en.maxTokens} 1`).placeholder).toBe('128K') fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: '65536' } }) fireEvent.click(within(screen.getByRole('group', { name: `${en.modelInputTypes} 1` })).getByRole('checkbox', { name: en.modelInputImage })) fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 60a03011cf..4bb075eb7e 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -54,8 +54,8 @@ en: 83031e7505fc228a zh: 03e17c9428fe29fd /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards: - en: ea89ec5a3ee1f167 - zh: e11f350571bcfff7 + en: 8bcb7bd90d7c5773 + zh: 17663be926a099d2 /deepseek-ai-dsh-client-ui-workspace/further-exploration: en: 2abbf27abbd71ba4 zh: ace33fa2ea042357 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 2654447b77..ecfc8f52b5 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -184,7 +184,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu ### Hover cards -Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card, including a blank New Session card, shows its working directory below the title, abbreviating a POSIX home prefix as `~`, and omits that line when the Host reports no directory or an empty one. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c655995453..2b51da8011 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -184,7 +184,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序 ### 悬浮卡片 -Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。 +Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。Session 卡片(包括空白「新会话」卡片)在标题下方显示其工作目录,POSIX 主目录前缀缩写为 `~`;Host 未报告目录或目录为空时省略该行。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 216a6fc00f..7005cb418c 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -420,12 +420,14 @@ function PinnedIndicator({ t }: { t: RowTranslate }) { } /** - * Hover-card body: full title, relative time, the Session's own scheduled-task + * Hover-card body: full title, display working directory, relative time, the Session's own scheduled-task * section, and every relevant live status. The task section sits above the * status lines so they stay the card's trailing status line. */ -function SessionHoverContent({ node, now, renderSlot, t }: { +function SessionHoverContent({ node, cwd, now, renderSlot, t }: { node: SessionNode + /** Display working directory; absent omits the line. */ + cwd: string | undefined now: number renderSlot: RowRenderSlots t: RowTranslate @@ -437,6 +439,7 @@ function SessionHoverContent({ node, now, renderSlot, t }: { return (
    {displayTitle(node, t)}
    + {cwd !== undefined &&
    {cwd}
    } {/* Same placeholder rule as the row's trailing cell: no timestamp before the first prompt. */} {!node.blank &&
    {hoverTimeLabel(node.updatedAt, now, t)}
    } @@ -539,11 +542,12 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }: * its leading decoration, and its hover-card section. * @param props.onReveal - scroll this row into view after search navigation, then acknowledge it. * @param props.drag - optional row-drag target wiring; blank rows cannot start a drag. + * @param props.home - host account home for POSIX hover-path abbreviation. * @param props.t - the browser root's locale seat. * @returns the session row. */ export function SessionNodeItem({ - node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, t, + node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, home, t, }: { node: SessionNode currentId: string | undefined @@ -555,6 +559,8 @@ export function SessionNodeItem({ onReveal?: (() => void) | undefined /** Present on reorderable-list rows so every row can remain a drop target. */ drag?: RowDragProps | undefined + /** Host account home; POSIX home-rooted hover paths display as `~`. */ + home?: string | undefined t: RowTranslate } & PropsRenderSlots< | 'sidebar.workspaces.session.menu.item' @@ -693,7 +699,13 @@ export function SessionNodeItem({ return ( } + content={} openDelayMs={800} grouped disabled={menuOpen || drag?.active === true} diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index df871654ee..be4c78a9ef 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -633,6 +633,7 @@ function SessionTree({ ? () => { onSessionRevealed(node.id) } : undefined} drag={dragProps} + home={home} t={t} /> ) @@ -689,7 +690,7 @@ function SessionTree({ function FlatList({ list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, open, onSessionRenameRequest, usePanelInfo, setSessionOrder, workspaceReady, animationResetKey, - revealSessionId, onSessionRevealed, renderSlot, t, + revealSessionId, onSessionRevealed, renderSlot, home, t, }: Pick< SessionTreeProps, | 'useSessionStatus' @@ -704,6 +705,7 @@ function FlatList({ | 'onSessionRevealed' | 'rowState' | 'onLeaveArchivedOnly' + | 'home' | 't' > & { list: SessionListState @@ -778,6 +780,7 @@ function FlatList({ dropCommitted.current = false }, }} + home={home} t={t} /> ) @@ -1434,6 +1437,7 @@ export function WorkspaceBrowser({ setSessionOrder={saveSessionOrder} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} + home={home} t={t} /> ) diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index eb67d0ebe3..608c0b2a58 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -59,6 +59,8 @@ export interface SessionNode { /** In the registry-global archive set: shown grayed in place and not openable. */ archived: boolean updatedAt: number + /** Session working directory; absent when the Host reports none or an empty path. */ + cwd?: string } /** Session order selected by the Workspace browser. */ @@ -413,6 +415,7 @@ function sessionNode( pinned: !archived.has(s.id) && pinned.has(s.id), archived: archived.has(s.id), updatedAt: s.updatedAt, + ...(s.cwd === undefined || s.cwd === '' ? {} : { cwd: s.cwd }), ...(pendingInteraction === undefined ? {} : { pendingInteraction }), } } diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index f02cf3ca85..d7b1587b43 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { ComponentProps } from 'react' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -685,6 +685,39 @@ describe('workspace browser rows', () => { } }) + describe('Session hover-card working directory', () => { + const located: SessionNode = { + id: sid('s-cwd'), title: 'Located', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false, + cwd: '/home/u/project', + } + function hoverCard(node: SessionNode): void { + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(800) }) + } + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('shows a home-rooted directory as ~', () => { + hoverCard(located) + expect(screen.getByText('~/project')).toBeTruthy() + }) + + it('omits the line when the Host reports no directory', () => { + const { cwd: _cwd, ...withoutCwd } = located + hoverCard(withoutCwd) + expect(screen.getAllByText('Located')).toHaveLength(2) + expect(screen.queryByText(/project/)).toBeNull() + }) + + it('shows the directory on a blank New Session card', () => { + hoverCard({ ...located, blank: true }) + expect(screen.getAllByText('新会话')).toHaveLength(2) + expect(screen.getByText('~/project')).toBeTruthy() + }) + }) + it('session row menu opens without opening the session and Escape closes it', () => { const onOpen = vi.fn() const node: SessionNode = { diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 4b62826cbc..f53d63b275 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -297,6 +297,27 @@ describe('WorkspaceBrowser', () => { } }) + it.each(['workspace', 'flat', 'ungrouped'] as const)('%s Session hover card shows the Session directory with home as ~', (mode) => { + vi.useFakeTimers() + try { + localStorage.clear() + const preferences = createWorkspaceViewStore().create() + preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace') + preferences.actions.setGroupExpanded(mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha', true) + localStorage.setItem('dsh.workspace.view.v5', JSON.stringify(preferences.getSnapshot())) + mount({ + useSessions: hook(sessionState([summary('located', 1, { cwd: '/home/u/Documents/project' })])), + useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ['located'])])), + useHostInfo: selector => selector({ home: '/home/u', isLoopback: true }), + }) + fireEvent.pointerEnter(screen.getByText('located').closest('[role="treeitem"]')?.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(800) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { const pending = { ...workspaceState([]), diff --git a/packages/computer-use/README.i18n.yaml b/packages/computer-use/README.i18n.yaml index c6e98fc6e5..1b2c63a951 100644 --- a/packages/computer-use/README.i18n.yaml +++ b/packages/computer-use/README.i18n.yaml @@ -9,8 +9,8 @@ en: 77ea90624a004773 zh: fe6dbf8414a5aff0 /packages-computer-use/summary: - en: 3820da56a601d3f1 - zh: 358e65f8a7cf7446 + en: 7e5fcf9bb48c9677 + zh: abee2be288115045 /packages-computer-use/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 @@ -18,8 +18,8 @@ en: 9aa86e6451f60c3d zh: c5e6816e90f2d74b /packages-computer-use/related-documentation: - en: ad08d3417a0d4630 - zh: cfe8e03033c2fede + en: 58198f9f17e6120f + zh: 188fa0ec5c4678f3 /packages-computer-use/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/packages/computer-use/README.md b/packages/computer-use/README.md index 1b07c668ad..d19b0a3b07 100644 --- a/packages/computer-use/README.md +++ b/packages/computer-use/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Computer-use providers let models observe and operate a desktop. This group owns exclusive provider registration. Each provider owns its operations, tools, and platform requirements; the experimental Cua Driver providers live in the experimental group. +Computer-use providers let models observe and operate a desktop. This group contains the shared registry that prevents multiple providers from being enabled in one composition. DSH's [CUA implementation](../experimental/cua/README.md), platform providers, and opt-in bundle currently live in `experimental`, alongside the Cua Driver adapters. The CUA package documents the platform requirements for promotion to this group. ## Table of Contents @@ -32,7 +32,8 @@ Choose one provider and mount the shared registration service. ## Related documentation - [Computer use](../../docs/subsystems/computer-use.md) — capability ownership and provider choices. -- [Experimental packages](../experimental/README.md) — Cua Driver MCP and native providers. +- [Computer Use bundle](../experimental/cua-bundle/README.md) — enable native CUA and access its permission controls. +- [Experimental packages](../experimental/README.md) — native CUA packages and Cua Driver adapters. ## Dev Note diff --git a/packages/computer-use/README.zh.md b/packages/computer-use/README.zh.md index d1aa763117..34204bc7a0 100644 --- a/packages/computer-use/README.zh.md +++ b/packages/computer-use/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -计算机操作提供方让模型观察并操作桌面。本组负责提供方的独占注册。各提供方拥有自己的操作、工具和平台要求;实验性 Cua Driver 提供方位于 experimental 组。 +计算机操作提供方让模型观察并操作桌面。本组包含共享注册器,防止同一组合同时启用多个提供方。DSH 的 [CUA 实现](../experimental/cua/README.zh.md)、平台提供方和启用组合包目前位于 `experimental`,Cua Driver 适配包也位于该组。CUA 包文档说明了迁入本组所需的平台支持条件。 ## 目录 @@ -32,7 +32,8 @@ kind: "package-group" ## 相关文档 - [计算机操作](../../docs/subsystems/computer-use.zh.md) — 能力归属和提供方选择。 -- [实验性包](../experimental/README.zh.md) — Cua Driver MCP 和原生提供方。 +- [Computer Use 组合包](../experimental/cua-bundle/README.zh.md):启用原生 CUA 并使用权限管理功能。 +- [实验性包](../experimental/README.zh.md):原生 CUA 包和 Cua Driver 适配包。 ## 开发备注 diff --git a/packages/computer-use/computer-use/README.i18n.yaml b/packages/computer-use/computer-use/README.i18n.yaml index f35f34159f..94dc062523 100644 --- a/packages/computer-use/computer-use/README.i18n.yaml +++ b/packages/computer-use/computer-use/README.i18n.yaml @@ -9,8 +9,8 @@ en: 2efee0c5f0d577bc zh: fef4b81bfc121052 /deepseek-ai-dsh-computer-use/summary: - en: 4de047849e68701f - zh: 90a32c0e279b4b05 + en: 4cb331c550852b23 + zh: d638995ab250397b /deepseek-ai-dsh-computer-use/table-of-contents: en: d152484eb41ac6b4 zh: 405b30feaf48c087 @@ -21,8 +21,8 @@ en: 181679887837591a zh: cc2e96766ce0aa63 /deepseek-ai-dsh-computer-use/further-exploration: - en: b1bdbcbc150036b3 - zh: 1d356e0522587abc + en: 987295d677466c04 + zh: 9d7b40a04786490e /deepseek-ai-dsh-computer-use/model-experience: en: 8780c250dfaefaaa zh: 1c4b3598a131fb65 diff --git a/packages/computer-use/computer-use/README.md b/packages/computer-use/computer-use/README.md index 32fccb6d6f..fb39d02482 100644 --- a/packages/computer-use/computer-use/README.md +++ b/packages/computer-use/computer-use/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -A deployment can enable one computer-use provider at a time. Loading another provider fails with the registered provider name. Each provider supplies its own tools and desktop operations. This package adds no model-visible tools and does not coordinate concurrent Sessions. +A deployment can enable one computer-use provider at a time. Loading another provider fails with the registered provider name. Both DSH's native CUA and the Cua Driver adapters use this registry. Each provider supplies its own tools and desktop operations. This package adds no model-visible tools and does not coordinate concurrent Sessions. ## Table of Contents @@ -53,6 +53,9 @@ One private name owns the slot. Cordis effects remove contributions when their p ## Further Exploration - [Computer use](../../../docs/subsystems/computer-use.md) — provider selection and shared-desktop limits. +- [Computer Use bundle](../../experimental/cua-bundle/README.md) — enable DSH's native CUA and permission controls. +- [Shared CUA library](../../experimental/cua/README.md) — application/window objects, tool execution, and application consent. +- [macOS CUA provider](../../experimental/cua-macos/README.md) — DSH's Swift implementation and native helper lifecycle. - [Cua Driver MCP provider](../../experimental/computer-use-cua-driver-mcp/README.md) — use an installed driver. - [Cua Driver native provider](../../experimental/computer-use-cua-driver-native/README.md) — use the npm runtime. diff --git a/packages/computer-use/computer-use/README.zh.md b/packages/computer-use/computer-use/README.zh.md index 1b3dfb4e77..81e6b7e264 100644 --- a/packages/computer-use/computer-use/README.zh.md +++ b/packages/computer-use/computer-use/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -部署每次可启用一个计算机操作提供方。加载另一个提供方时会失败,并报告已注册的提供方名称。各提供方提供自己的工具和桌面操作。本包不添加模型可见工具,也不协调并发 Session。 +部署每次可启用一个计算机操作提供方。加载另一个提供方时会失败,并报告已注册的提供方名称。DSH 原生 CUA 和 Cua Driver 适配包都使用此注册器。各提供方提供自己的工具和桌面操作。本包不添加模型可见工具,也不协调并发 Session。 ## 目录 @@ -53,6 +53,9 @@ kind: "package-reference" ## 延伸阅读 - [计算机操作](../../../docs/subsystems/computer-use.zh.md) — 提供方选择和共享桌面限制。 +- [Computer Use 组合包](../../experimental/cua-bundle/README.zh.md):启用 DSH 原生 CUA 和权限管理功能。 +- [共享 CUA 库](../../experimental/cua/README.zh.md):应用与窗口对象、工具执行和应用授权。 +- [macOS CUA 提供方](../../experimental/cua-macos/README.zh.md):DSH 的 Swift 实现和原生辅助进程生命周期。 - [Cua Driver MCP 提供方](../../experimental/computer-use-cua-driver-mcp/README.zh.md) — 使用已安装的驱动。 - [Cua Driver 原生提供方](../../experimental/computer-use-cua-driver-native/README.zh.md) — 使用 npm 运行时。 diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 3288e906b5..72331f71e6 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -35,7 +35,6 @@ "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 74d6e5fa22..ec752e0715 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -15,7 +15,6 @@ import { isDeepStrictEqual } from 'node:util' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' @@ -33,7 +32,7 @@ import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, ty export { Config, name } /** Services required by workspace instruction projection. */ -export const inject = ['sessionProjections', 'workingDirectory'] +export const inject = ['agents', 'workingDirectory'] export { discoverBaselineInstructionFiles, loadBaselineInstructions, @@ -303,10 +302,7 @@ export function apply(ctx: Context, config: Config): void { } const stepIsOpen = (session: Session): boolean => { - const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary') - if (boundary === undefined) { - throw new Error('agent-instructions requires the turnBoundary session projection') - } + const boundary = ctx.agents.turnBoundaryOf(session) return boundary.openTurnStartSeq !== null && boundary.lastStepBoundary?.kind === 'start' && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 64f68d4968..dc7ddb9df5 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -5,13 +5,14 @@ import { tmpdir } from 'node:os' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import type { Config as AgentInstructionsConfig } from '@deepseek-ai/dsh-agent-instructions' import * as AgentInstructions from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, ToolCallId, type Message, type MessageSource, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, SessionSeq, type SessionEvent, type SurfaceIntent, type UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -278,19 +279,19 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } -async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructionsConfig): Promise>> { if (ctx.get('workingDirectory') === undefined) provideWorkingDirectoryFixture(ctx) - if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + if (ctx.get('sessionProjections') === undefined) if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) + if (ctx.get('agents') === undefined) await ctx.plugin(AgentRegistry) return ctx.plugin(AgentInstructions, config) } -async function mountAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) return mountAgentInstructionsPlugin(ctx, config) } -async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) @@ -1836,7 +1837,7 @@ describe('workspace context request injection', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) - await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructions.Config)).rejects.toThrow(/maxBytes/) + await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructionsConfig)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { @@ -1849,30 +1850,8 @@ describe('workspace context request injection', () => { } }) - it('requires projections without making the optional filesystem a static dependency', () => { - expect(AgentInstructions.inject).toEqual(['sessionProjections', 'workingDirectory']) - }) - - it('rejects a file-touch projection when the turn boundary unit is absent', async () => { - const ctx = new Context() - provideWorkingDirectoryFixture(ctx) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentInstructions, { maxBytes: 65536 }) - const exec = stubToolExecution({ - callId: ToolCallId('missing-turn-boundary'), - name: 'read', - arguments: { file_path: 'file.txt' }, - agent: await stubAgent('/virtual/repo'), - signal: testToolSignal, - }) - - expect(() => { - ctx.emit('tools/result', exec, { - content: [{ type: 'text', text: 'ok' }], - isError: false, - value: null, - }) - }).toThrow('agent-instructions requires the turnBoundary session projection') + it('requires agents without making the optional filesystem a static dependency', () => { + expect(AgentInstructions.inject).toEqual(['agents', 'workingDirectory']) }) it('does not inject baseline context when no filesystem provider is present', async () => { diff --git a/packages/context/agent-instructions/tsconfig.json b/packages/context/agent-instructions/tsconfig.json index 56237a330b..f8940375c5 100644 --- a/packages/context/agent-instructions/tsconfig.json +++ b/packages/context/agent-instructions/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../core/session" }, - { - "path": "../../session/session-projection" - }, { "path": "../../core/tools" }, diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts index 19ec85c3f6..88cef08184 100644 --- a/packages/context/file-reference-local/tests/service.spec.ts +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -26,9 +26,9 @@ async function harness(): Promise { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(WorkingDirectory) return ctx } @@ -64,6 +64,7 @@ describe('LocalFileReferenceService', () => { it('defers guidance until optional prompt and tool services become available', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) ctx.provide('workingDirectory', { ensure: vi.fn() }) await ctx.plugin(LocalFileReferenceService) diff --git a/packages/context/harness-dev-context/README.i18n.yaml b/packages/context/harness-dev-context/README.i18n.yaml index adc0b95c0e..5a9ee319b8 100644 --- a/packages/context/harness-dev-context/README.i18n.yaml +++ b/packages/context/harness-dev-context/README.i18n.yaml @@ -33,8 +33,8 @@ en: 4696b6c3a1f91f96 zh: 3a56d257ec768a5e /deepseek-ai-dsh-harness-dev-context/understand-the-implementation/main-flow: - en: f3844a1825b2e329 - zh: bdcafbcadbc6d316 + en: 55c4cdd475f9f851 + zh: ac4fb96e7be88810 /deepseek-ai-dsh-harness-dev-context/further-exploration: en: f291741c499581ac zh: 8ee0baef51e69ef0 diff --git a/packages/context/harness-dev-context/README.md b/packages/context/harness-dev-context/README.md index 04d3925181..3d4ca78246 100644 --- a/packages/context/harness-dev-context/README.md +++ b/packages/context/harness-dev-context/README.md @@ -57,7 +57,7 @@ A host-only session projection, `harnessDevLegacyPrompt`, folds every committed ### Main flow -Activation registers the projection unit, both sections, and both contexts. At each assembly the sections choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration. +Activation awaits the projection handle before registering both sections and both contexts. At each assembly the sections read the handle to choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration. diff --git a/packages/context/harness-dev-context/README.zh.md b/packages/context/harness-dev-context/README.zh.md index e108c6f161..b25f5eea66 100644 --- a/packages/context/harness-dev-context/README.zh.md +++ b/packages/context/harness-dev-context/README.zh.md @@ -57,7 +57,7 @@ kind: "package-reference" ### 主流程 -激活时注册投影单元、两个段落与两个上下文。每次组装时,段落在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。 +激活时等待取得投影 handle,再注册两个段落与两个上下文。每次组装时,段落读取 handle,在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。 diff --git a/packages/context/harness-dev-context/src/index.ts b/packages/context/harness-dev-context/src/index.ts index becb4a6005..40ae8f38f1 100644 --- a/packages/context/harness-dev-context/src/index.ts +++ b/packages/context/harness-dev-context/src/index.ts @@ -85,13 +85,6 @@ const legacyPromptSchema = zod.object({ source: zod.string().nullable(), web: zo /** Value-bearing paragraphs of the latest nonempty system prompt a Session committed; `null` where it has none. */ type LegacyPrompt = zod.infer -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Value-bearing harness-source and Web-surface paragraphs of the latest nonempty admitted system prompt. */ - harnessDevLegacyPrompt: LegacyPrompt - } -} - /** The complete single-line paragraph between `prefix` and `suffix`, or `null` when the prompt has none. */ function legacyParagraph(prompt: string, { prefix, suffix }: { prefix: string; suffix: string }): string | null { const start = prompt.indexOf(prefix) @@ -124,9 +117,10 @@ const ADMITS_NOTHING: LegacyPrompt = { source: null, web: null } * Register the two sections, their runtime-context values, and the * projection that keeps a Session's admitted paragraphs. * @param ctx - plugin context with every {@link inject} service. + * @returns after the projection reader and prompt registrations are ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register({ +export async function apply(ctx: Context): Promise { + const legacyPromptProjectionHandle = await ctx.sessionProjections.register({ key: LEGACY_PROMPT, stateVersion: 1, stateSchema: legacyPromptSchema, @@ -135,8 +129,7 @@ export function apply(ctx: Context): void { }) const admitted = (context: AssembleContext): LegacyPrompt => { const session = context.agent?.session - // This plugin registers the unit above, so its state exists for every Session. - return session === undefined ? ADMITS_NOTHING : ctx.sessionProjections.stateOf(session, LEGACY_PROMPT) as LegacyPrompt + return session === undefined ? ADMITS_NOTHING : legacyPromptProjectionHandle.stateOf(session) } ctx.systemPrompt.section({ name: HARNESS_SOURCE, diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index efc8447e90..b896bd5217 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { agentEvents, installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import LlmRuntime, { createDeveloperMessage, createMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -50,6 +50,7 @@ async function harness(config: Config = {}): Promise { // The live registry and the title unit it hosts: discovery labels an // attached session from its projection cut, never from its log. await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) // Shipped base values: this suite only needs the unit the service registers. await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }) await ctx.plugin(TestSessionQueryEngine) diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index 0c6f23cd4b..00bb63502d 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -36,8 +36,8 @@ en: 269d7aa1f64c4278 zh: 086018a60cc3a351 /deepseek-ai-dsh-time-context/understand-the-implementation/main-flow: - en: ae9f13873dde5271 - zh: 873f32e67f8b202b + en: f09994721526e830 + zh: 6f1a463355111513 /deepseek-ai-dsh-time-context/further-exploration: en: e1cd55f40edbb7ed zh: 8a2abf22ef859841 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 4d03796d30..cc5e40fda8 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -76,6 +76,8 @@ The plugin prepends an `agent/pre-step` listener that delegates first and append ### Main flow +The pre-step listener is installed before awaiting projection registration. Steps arriving during activation wait for the reader; registration failure or owner disposal stops the request, and cancellation while waiting produces no reading. + When an injection is due, the plugin samples the wall clock, derives the browser-zone policy from the open turn's `user-rpc` messages, resolves the display zone (request-local or fallback), and renders the three-line reading. Positive-interval scheduling scans raw durable session events for the latest plugin-attributed message — including one shadowed by compaction — so the schedule survives resume without a process-local cache. A reading records an entered step, not a completed or transmitted request; a later preparation failure can leave it in history. diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index b55a778d5c..f392be5216 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -76,6 +76,8 @@ kind: "package-reference" ### 主要流程 +pre-step 监听器在等待投影注册前安装。激活期间到达的步骤会等待读取句柄;注册失败或所属插件释放会终止请求,等待期间取消不会产生读数。 + 需要注入时,插件采样挂钟时间,从开放轮次的 `user-rpc` 消息派生浏览器时区策略,解析显示时区(请求本地或回退),并渲染三行读数。正数间隔调度会扫描原始持久会话事件,查找最新一条归因于插件的消息——包括被压缩(compaction)遮蔽的读数——因此调度无需进程本地缓存也能在恢复后存续。读数记录的是已进入的步骤,不是已完成或已传输的请求;后续准备失败时,该读数可能留在历史中。 diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index edb9343501..44f5899cbf 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -38,7 +38,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*" + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*" } } diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 493630a811..c1d959ecd2 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -19,6 +19,7 @@ declare module '@deepseek-ai/dsh-llm' { import type { UserMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' +import { Duration } from '@deepseek-ai/dsh-util-time' import type {} from '@deepseek-ai/dsh-session-projection' import { deriveBrowserTimeZoneContext, @@ -30,13 +31,6 @@ import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Latest time-context readings. */ - timeContext: TimeContextProjection - } -} - const timeContextStateSchema = zod.object({ /** Time of the latest model-visible event (user/assistant message, tool result), or null. */ lastMessageTime: zod.number().nullable(), @@ -46,9 +40,6 @@ const timeContextStateSchema = zod.object({ lastTurnInjectionTime: zod.number().nullable(), }) -/** Folded time-context readings. */ -type TimeContextProjection = zod.infer - /** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] @@ -66,15 +57,17 @@ export const Config: z = z.object({ refreshIntervalMs: z.number(), }) -/** Format a non-negative elapsed millisecond count as compact whole-second units. */ +/** + * Format a non-negative elapsed millisecond count as compact whole-second units. + * + * A backward wall-clock reading is a property of the clock rather than of the + * span, so it clamps to zero here; the shared span type then supplies the whole + * parts, and this package keeps the wording. + */ function formatDuration(elapsedMs: number): string { - let seconds = Math.floor(Math.max(0, elapsedMs) / 1000) - const days = Math.floor(seconds / 86_400) - seconds %= 86_400 - const hours = Math.floor(seconds / 3600) - seconds %= 3600 - const minutes = Math.floor(seconds / 60) - seconds %= 60 + const { days, hours, minutes, seconds } = Duration + .fromMilliseconds(Math.max(0, elapsedMs)) + .decompose() const parts: string[] = [] if (days > 0) parts.push(`${days}d`) if (hours > 0) parts.push(`${hours}h`) @@ -125,11 +118,14 @@ function validateRefreshInterval(refreshIntervalMs: number): void { /** * Register a prepended pre-step listener for the lifetime of `ctx`. + * Steps admitted during activation await the projection registration; a + * registration failure or owner disposal prevents the request from proceeding. * @param ctx - plugin context; the listener is disposed with it. * @param config - time zone and durable refresh scheduling configuration. * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { const timeZone = config.timeZone const refreshIntervalMs = config.refreshIntervalMs ?? 600_000 validateRefreshInterval(refreshIntervalMs) @@ -154,7 +150,7 @@ export function apply(ctx: Context, config: Config): void { return created } - ctx.sessionProjections.register({ + const projectionReady = ctx.sessionProjections.register({ key: 'timeContext', stateVersion: 2, stateSchema: timeContextStateSchema, @@ -186,10 +182,12 @@ export function apply(ctx: Context, config: Config): void { { agent, turn, step, signal }, next, ): Promise => { + const timeContextProjectionHandle = await projectionReady + ctx.fiber.assertActive() const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() - const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') as TimeContextProjection + const state = timeContextProjectionHandle.stateOf(agent.session) if (refreshIntervalMs > 0) { const lastInjection = state.lastInjectionTime if (lastInjection != null @@ -223,4 +221,5 @@ export function apply(ctx: Context, config: Config): void { ], } }, { prepend: true }) + await projectionReady } diff --git a/packages/context/time-context/src/request-zone.ts b/packages/context/time-context/src/request-zone.ts index 41dad757af..800cd04124 100644 --- a/packages/context/time-context/src/request-zone.ts +++ b/packages/context/time-context/src/request-zone.ts @@ -1,17 +1,22 @@ /** Browser-zone derivation and model-facing policy text for one open request turn. */ +import { canonicalizeTimeZone } from '@deepseek-ai/dsh-util-time' import type { UserMessage } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - /** Browser-zone facts derived from user-rpc messages in one open turn. */ export type BrowserTimeZoneContext = | { readonly kind: 'resolved'; readonly timeZone: string } | { readonly kind: 'mixed'; readonly timeZones: readonly string[] } | { readonly kind: 'missing' } -/** Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. */ +/** + * Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. + * + * A request may only name a zone another process resolves back to the same + * identity, so the shared vocabulary's canonical answer must equal the reported + * value; an alias is refused here rather than silently rewritten. + */ function browserTimeZone(message: UserMessage): string | undefined { const source = message.source const value = source.kind === 'user' @@ -22,21 +27,27 @@ function browserTimeZone(message: UserMessage): string | undefined { ? source.clientTimeZone : undefined if (value === undefined) return undefined - if (value !== 'UTC' && !IANA_TIME_ZONE.test(value)) { - throw new TypeError( - `browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`, - ) + const resolved = canonicalizeTimeZone(value) + if (resolved.kind === 'accepted') { + if (resolved.timeZone !== value) { + throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) + } + return value } - let canonical: string - try { - canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone - } catch (error: unknown) { - throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: error }) + switch (resolved.reason) { + case 'unsupported': + throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: resolved.cause }) + case 'blank': + case 'padded': + case 'not_area_location': + throw new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`) + /* v8 ignore next 2 -- Intl answers a canonical Area/Location for every name it accepts. */ + case 'not_canonical_shape': + throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) + /* v8 ignore next 2 -- TimeZoneRejection is closed; this arm only makes adding a reason a compile error. */ + default: + assertNever(resolved.reason, 'TimeZoneRejection') } - if (canonical !== value) { - throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) - } - return value } /** diff --git a/packages/context/time-context/tests/initialization.spec.ts b/packages/context/time-context/tests/initialization.spec.ts new file mode 100644 index 0000000000..ab3d404ce4 --- /dev/null +++ b/packages/context/time-context/tests/initialization.spec.ts @@ -0,0 +1,68 @@ +/** Real Agent requests overlapping time-context projection registration. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as timeContext from '@deepseek-ai/dsh-time-context' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +describe('time-context activation', () => { + it.each(['ready', 'cancelled', 'disposed', 'failed'] as const)('handles a %s registration before the first request', async (outcome) => { + const ctx = new Context() + const registered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new MockAdapter([textResponse('ready')]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = await ctx.agentLoop.create(SessionId('initial-time'), { provider: 'mock', model: 'mock' }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'timeContext') { + registered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(timeContext, { timeZone: 'UTC' }) + await registered.promise + const preparing = Promise.withResolvers() + ctx.on('agent/pre-step', (_payload, next) => { + preparing.resolve(undefined) + return next() + }, { prepend: true }) + const errors: unknown[] = [] + ctx.on('agent/error', ({ error }) => { errors.push(error) }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'what time is it?' }], source: { kind: 'user' } })) + await preparing.promise + + if (outcome === 'cancelled') agent.cancel({ kind: 'user' }) + const disposal = outcome === 'disposed' ? fiber.dispose() : undefined + if (outcome === 'failed') { + const rejected = expect(Promise.resolve(fiber)).rejects.toThrow('time registration failed') + release.reject(new Error('time registration failed')) + await rejected + } else { + release.resolve(undefined) + await (disposal ?? fiber) + } + await agent.whenIdle() + + const readings = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'time-context') + expect(readings).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(adapter.requests).toHaveLength(outcome === 'ready' ? 1 : 0) + if (outcome === 'ready') { + expect(adapter.requests[0]?.messages.flatMap(message => message.content) + .some(block => block.type === 'text' && block.text.startsWith('Time sampled while preparing turn 1, step 1:'))).toBe(true) + } + if (outcome === 'failed') expect(errors).toContainEqual(new Error('time registration failed')) + }) +}) diff --git a/packages/context/time-context/tests/request-zone.spec.ts b/packages/context/time-context/tests/request-zone.spec.ts index a4752eea73..74eb828337 100644 --- a/packages/context/time-context/tests/request-zone.spec.ts +++ b/packages/context/time-context/tests/request-zone.spec.ts @@ -53,6 +53,29 @@ describe('browser request-zone context', () => { ])).toThrow(/browser time zone must be canonical/) }) + it('keeps the exact refusal and the platform cause for every unusable zone', () => { + const refusal = (timeZone: string): Error => { + try { + deriveBrowserTimeZoneContext([browserMessage(timeZone)]) + throw new Error('expected a browser-zone refusal') + } catch (error: unknown) { + expect(error).toBeInstanceOf(TypeError) + return error as Error + } + } + expect(refusal('').message) + .toBe('browser time zone must be canonical UTC or IANA Area/Location: ""') + expect(refusal('+08:00').message) + .toBe('browser time zone must be canonical UTC or IANA Area/Location: "+08:00"') + expect(refusal(' UTC').message) + .toBe('browser time zone must be canonical UTC or IANA Area/Location: " UTC"') + expect(refusal('Etc/UTC').message).toBe('browser time zone must be canonical: "Etc/UTC"') + const unsupported = refusal('Not/A_Real_Zone') + expect(unsupported.message).toBe('browser time zone is unsupported: "Not/A_Real_Zone"') + // The platform refusal stays the operator's only evidence of why a well-formed name failed. + expect((unsupported as Error & { cause?: unknown }).cause).toBeInstanceOf(RangeError) + }) + it('renders one explicit model policy for every context', () => { expect(renderBrowserTimeZoneContext({ kind: 'resolved', timeZone: 'Asia/Shanghai' })) .toContain('Interpret otherwise-unqualified dates and times in this zone.') diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 92499007d1..a795935f2e 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -429,10 +429,10 @@ describe('time-context projection fold edges', () => { content: [{ type: 'text', text: 'reading' }], source: { kind: 'time-context' }, }), { surfaceOp: 'append' }) - expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number') + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toHaveProperty('lastTurnInjectionTime', expect.any(Number)) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2 }) - expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ lastTurnInjectionTime: null, }) }) @@ -441,7 +441,7 @@ describe('time-context projection fold edges', () => { const { ctx } = await mount() const session = Session.create(SessionId('end-without-start')) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ lastTurnInjectionTime: null, }) }) diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index c8284be70f..e947d3a439 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../session/session-projection" + }, + { + "path": "../../util/time" } ] } diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml index 5535862beb..4a14f8421c 100644 --- a/packages/context/tmux-context/README.i18n.yaml +++ b/packages/context/tmux-context/README.i18n.yaml @@ -36,8 +36,8 @@ en: 14bc5e3279f9da27 zh: 90953dfbf892033a /deepseek-ai-dsh-tmux-context/understand-the-implementation/main-flow: - en: 2d5768fe44120780 - zh: cd601c59d9604e6e + en: ffe590c1e3c46225 + zh: 5c0acd97c4bad094 /deepseek-ai-dsh-tmux-context/further-exploration: en: 08c09f5eb13c30e6 zh: 1b34ef9ca42ac9e7 diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md index 50c3f47312..b2434f0321 100644 --- a/packages/context/tmux-context/README.md +++ b/packages/context/tmux-context/README.md @@ -73,6 +73,8 @@ The plugin prepends an `agent/pre-step` listener that runs only on the first ste ### Main flow +The pre-step listener is installed before awaiting projection registration. Steps arriving during activation wait for the reader; registration failure or owner disposal stops the request, and cancellation while waiting performs no shell query or context injection. + At the first step of a turn, the listener checks whether an injection is due, queries the location through `ctx.shell`, and compares the rendered state with the latest durable injection of this source. Change suppression and interval scheduling scan the raw durable session events, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. A downstream pre-step listener that rejects or fails prevents the reading from being recorded. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md index 0f4e707d40..9076c11224 100644 --- a/packages/context/tmux-context/README.zh.md +++ b/packages/context/tmux-context/README.zh.md @@ -73,6 +73,8 @@ kind: "package-reference" ### 主要流程 +pre-step 监听器在等待投影注册前安装。激活期间到达的步骤会等待读取句柄;注册失败或所属插件释放会终止请求,等待期间取消不会执行 shell 查询或注入上下文。 + 在每轮的第一个步骤,监听器检查注入是否到期,通过 `ctx.shell` 查询位置,并把渲染状态与该来源最近一次持久注入比较。变化抑制与间隔调度会扫描原始持久会话事件,因此调度可跨压缩(compaction)与恢复的进程存续,无需进程内缓存状态;各会话独立调度。下游在步骤前运行的监听器拒绝或失败时,该读数不会被记录。 diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index af6d636e93..9e3b69d8b0 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -26,6 +26,7 @@ import type {} from '@deepseek-ai/dsh-session-projection' import type { ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' + declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { /** Location attribution; readers preserve the content without this producer. @@ -36,7 +37,6 @@ declare module '@deepseek-ai/dsh-llm' { } } - /** Cordis plugin name used by loader diagnostics. */ export const name = 'tmux-context' @@ -208,26 +208,20 @@ const tmuxContextStateSchema = zod.object({ time: zod.number(), }).nullable() -type TmuxContextState = zod.infer - /** * Register a prepended pre-step listener for the lifetime of `ctx`. + * Steps admitted during activation await the projection registration; a + * registration failure or owner disposal prevents the request from proceeding. * @param ctx - plugin context; the listener is disposed with it. * @param config - durable refresh scheduling configuration. + * @returns when the listener and projection are ready. * @throws when the refresh interval is invalid. */ -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** The stable state block of this plugin's latest durable injection, or null. */ - tmuxContext: TmuxContextState - } -} - -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - ctx.sessionProjections.register({ + const projectionReady = ctx.sessionProjections.register({ key: 'tmuxContext', stateVersion: 1, stateSchema: tmuxContextStateSchema, @@ -247,11 +241,13 @@ export function apply(ctx: Context, config: Config): void { { agent, turn, step, signal }, next, ): Promise => { + const tmuxContextProjectionHandle = await projectionReady + ctx.fiber.assertActive() const decision = await next() if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('shell') if (bash === undefined) return decision - const previous = ctx.sessionProjections.stateOf(agent.session, 'tmuxContext') as TmuxContextState + const previous = tmuxContextProjectionHandle.stateOf(agent.session) if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision @@ -272,4 +268,5 @@ export function apply(ctx: Context, config: Config): void { ], } }, { prepend: true }) + await projectionReady } diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index ae15f20a46..3c1444e25c 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' @@ -8,7 +8,9 @@ import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' import type { Config } from '@deepseek-ai/dsh-tmux-context' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { mountAgentLoopTestDependencies, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** Empty offset readers for fakes that never produce output. */ const silentReader = { readFrom: (fromByte: number) => ({ text: '', nextOffset: fromByte, lossy: false }) } @@ -93,8 +95,8 @@ async function mount( withBash = false, ): Promise<{ ctx: Context; bash: FakeBash | undefined }> { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) let bash: FakeBash | undefined if (withBash) { await ctx.plugin(FakeBash) @@ -167,6 +169,61 @@ afterEach(() => { }) describe('tmux-context injection', () => { + it.each(['ready', 'cancelled', 'disposed', 'failed'] as const)('handles a %s registration before querying the first turn location', async (outcome) => { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(FakeBash) + const bash = ctx.shell as FakeBash + const adapter = new MockAdapter([textResponse('ready')]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = await ctx.agentLoop.create(SessionId('initial-tmux'), { provider: 'mock', model: 'mock' }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'tmuxContext') { + entered.resolve(undefined) + await release.promise + } + return handle + }) + const fiber = ctx.plugin(tmuxContext, {}) + await entered.promise + const preparing = Promise.withResolvers() + ctx.on('agent/pre-step', (_payload, next) => { + preparing.resolve(undefined) + return next() + }, { prepend: true }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'where is this terminal?' }], source: { kind: 'user' } })) + await preparing.promise + if (outcome === 'cancelled') agent.cancel({ kind: 'user' }) + const disposal = outcome === 'disposed' ? fiber.dispose() : undefined + if (outcome === 'failed') { + const rejected = expect(Promise.resolve(fiber)).rejects.toThrow('tmux registration failed') + release.reject(new Error('tmux registration failed')) + await rejected + } else { + release.resolve(undefined) + await (disposal ?? fiber) + } + await agent.whenIdle() + + expect(bash.commands).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(contextTexts(agent.session)).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(adapter.requests).toHaveLength(outcome === 'ready' ? 1 : 0) + if (outcome === 'ready') { + expect(adapter.requests[0]?.messages.flatMap(message => message.content)).toContainEqual({ + type: 'text', text: contextTexts(agent.session)[0], + }) + } + }) + it('injects the tmux location on the first step of a turn', async () => { const { ctx } = await mount({}, true) const session = Session.create(SessionId('first')) diff --git a/packages/core/agent-controller/package.json b/packages/core/agent-controller/package.json index 218037e4c0..3750eaee8a 100644 --- a/packages/core/agent-controller/package.json +++ b/packages/core/agent-controller/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/core/agent-controller/tests/agent-controller.spec.ts b/packages/core/agent-controller/tests/agent-controller.spec.ts index a3c4963769..344492db9b 100644 --- a/packages/core/agent-controller/tests/agent-controller.spec.ts +++ b/packages/core/agent-controller/tests/agent-controller.spec.ts @@ -10,6 +10,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { UserMessage } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentController, { ADMISSION_AGENT_GONE, ADMISSION_FAILED, AgentAdmissionRejected } from '@deepseek-ai/dsh-agent-controller' import type { DeliveryMode, DeliveryRequest } from '@deepseek-ai/dsh-agent-controller' @@ -79,6 +80,7 @@ async function harness(): Promise<{ }> { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const controllerFiber = await ctx.plugin(AgentController) const recordingAgent = async (rawId: string, deliveries: Delivery[], refusal?: Error) => { diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 0bbdb05d37..ab692943de 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -39,8 +39,8 @@ en: 2a8b93869eb5a2f9 zh: ddab8e738d188a61 /deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown: - en: d079cf181ece9267 - zh: 047f76fecfc5497c + en: 79aa89d1db536612 + zh: a86635bf4aa54b59 /deepseek-ai-dsh-agent-loop/understand-the-implementation/persistence-integration: en: 3c5721bc00a0bbf4 zh: 711f832343ffbb75 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 62dcd35a92..7308c64c73 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -109,7 +109,7 @@ The loop deep-freezes each derived message identity on its first request and reu Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created`; await serial `agent/created` listeners; then release queued input. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. Setup, commit, or listener failure and owner disposal roll back the prepared resources. Announcements already delivered remain observable and receive matching disposal notifications. Teardown stops and drains the driver, unwinds the scope, closes the session's write path, detaches the agent, then detaches the session. Every detach binds the exact entered object, so a stale disposer cannot remove a later same-id replacement. -Factory unload retains the inbox and turn-boundary projections through scope cleanup, including cancellation from scoped abort listeners, until every Agent teardown and startup task settles. Cleanup failures do not release those projections early. Caller unload, factory unload, and handle disposal await the same teardown. +The loop awaits inbox and turn-boundary registration before publishing its factory and passes those handles into synchronous Agent construction. During factory unload, global registrations may disappear before teardown finishes; retained handles remain readable through scope cleanup, including cancellation from scoped abort listeners. Caller unload, factory unload, and handle disposal await the same teardown. ### Persistence integration diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ee355a328d..714f3f3839 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -109,7 +109,7 @@ const handle = await ctx.agents.create({ 创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;宣告 `session/created`;等待串行 `agent/created` 监听器;随后释放已排队输入。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活句柄。Setup、commit、监听器失败或所有者 dispose 都会回滚已准备的资源。已送达的宣告仍可被观察,并有配对的销毁通知。Teardown 停止并排空驱动器、撤销作用域、关闭会话写路径、detach agent,再 detach 会话。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 -工厂卸载时在整个作用域清理期间保留 inbox 投影与轮次边界投影,包括作用域内 abort 监听器发起取消的时刻,直到所有 Agent teardown 和启动任务结束。清理失败不会使这些投影提前释放。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 +loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄传入 Agent 的同步构造过程。工厂卸载时,全局注册可以先于 teardown 完成而消失;保留句柄在整个作用域清理期间仍可读取,包括作用域内 abort 监听器发起取消的时刻。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 ### 持久化集成 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index adf2e33ddc..20154c10fc 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,6 +12,7 @@ import type { AgentStatus, CancelOptions, InboxTarget, + InboxState, PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' @@ -31,7 +32,6 @@ import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnE import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { ReactLoopInbox } from './inbox.ts' import { RuntimeContextProjection } from './runtime-context.ts' @@ -124,14 +124,14 @@ export class ReactLoopAgent implements Agent { public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, + readInbox: () => InboxState, + lastTurn: number, ) { this.requestSurfaceGeneration = session.surface.contentGeneration this.dispatch = agentEvents(loopCtx, this) this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx - this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch) - /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */ - const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0 + this.inbox = new ReactLoopInbox(readInbox, session, this.dispatch) this.phase = { kind: 'idle', lastTurn } this.runtimeContext = new RuntimeContextProjection(this.ctx, session) this.systemPrompt = new SystemPromptProjection(session) diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index ef00887a54..affe0c6b2f 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -6,7 +6,6 @@ import type { MessageId } from '@deepseek-ai/dsh-llm' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' import type { AgentEventDispatch, @@ -67,30 +66,30 @@ export const inboxProjectionDefinition = { /** * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused * provider tests. - * @param projections - registry with the standard Inbox projection registered by AgentLoop. + * @param readState - reader for the live Inbox projection state. * @param session - session whose durable events store pending input. * @param dispatch - agent-scoped notifications for Inbox lifecycle events. */ export class ReactLoopInbox implements InboxContract { constructor( - private readonly projections: SessionProjectionRegistry, + private readonly readState: () => InboxState, private readonly session: Session, private readonly dispatch: AgentEventDispatch, ) {} /** Prompts awaiting individual turns. */ get nextTurn(): readonly UserMessage[] { - return this.current()['next-turn'] + return this.readState()['next-turn'] } /** Input awaiting the next step boundary. */ get nextStep(): readonly UserMessage[] { - return this.current()['next-step'] + return this.readState()['next-step'] } /** Whether either pending-message list contains work. */ get hasPending(): boolean { - const state = this.current() + const state = this.readState() return state['next-turn'].length > 0 || state['next-step'].length > 0 } @@ -119,7 +118,7 @@ export class ReactLoopInbox implements InboxContract { * @param message - message to append. */ append(target: InboxTarget, message: UserMessage): void { - this.splice(target, this.current()[target].length, 0, [message]) + this.splice(target, this.readState()[target].length, 0, [message]) } /** @@ -175,7 +174,7 @@ export class ReactLoopInbox implements InboxContract { /** Locate one pending identity across both owned lists. */ private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { - const state = this.current() + const state = this.readState() for (const target of ['next-turn', 'next-step'] as const) { const index = state[target].findIndex(message => message.id === messageId) if (index >= 0) return { target, index } @@ -183,17 +182,6 @@ export class ReactLoopInbox implements InboxContract { return undefined } - /** Read the current durable projection state. */ - private current(): InboxState { - const state = this.projections.stateOf(this.session, 'inbox') - if (state === undefined) { - throw new Error( - `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`, - ) - } - return state - } - /** Commit one normalized mutation and publish its live events. */ private mutate( target: InboxTarget, @@ -202,7 +190,7 @@ export class ReactLoopInbox implements InboxContract { inserted: UserMessage[], discardRemoved: boolean, ): UserMessage[] { - const state = this.current() + const state = this.readState() const inbox = state[target] const truncatedStart = Math.trunc(start) const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ca4532af8c..dc68fef4e0 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -9,7 +9,6 @@ import type { Volatile } from '@deepseek-ai/cosmokit' import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' -import { z as zod } from 'zod' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, @@ -20,15 +19,14 @@ import type { CreateAgentOptions, ResumeAgentOptions, SessionStartSource, - TurnBoundaryProjection, + InboxState, } from '@deepseek-ai/dsh-agent' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers, SessionLogOffset, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { ReactLoopAgent } from './agent.ts' @@ -43,57 +41,6 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) -const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ - openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), - lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), - lastStepBoundary: zod.object({ - kind: zod.union([zod.literal('start'), zod.literal('end')]), - seq: zod.number().int().nonnegative().transform(SessionSeq), - }).nullable(), - lastTurn: zod.number().int().nonnegative(), -}) - -/** Host projection of agent turn and step boundaries. */ -export const turnBoundaryProjectionDefinition = { - key: 'turnBoundary', - stateVersion: 2, - stateSchema: turnBoundaryProjectionSchema, - init: () => ({ - openTurnStartSeq: null, - lastStepStartSeq: null, - lastStepBoundary: null, - lastTurn: 0, - }), - apply: (state, event) => { - switch (event.type) { - case 'turn/start': - return { - ...state, - openTurnStartSeq: event.seq, - lastTurn: event.data.turn, - } - case 'turn/end': - return { - ...state, - openTurnStartSeq: null, - } - case 'step/start': - return { - ...state, - lastStepStartSeq: event.seq, - lastStepBoundary: { kind: 'start', seq: event.seq }, - } - case 'step/end': - return { - ...state, - lastStepBoundary: { kind: 'end', seq: event.seq }, - } - default: - return state - } - }, -} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> - /** Re-throw one collected failure as itself, or several as one `AggregateError`. */ function throwCollectedFailures(failures: readonly unknown[], message: string): void { if (failures.length === 1) throw failures[0] @@ -361,6 +308,7 @@ export class AgentLoop extends Service implements AgentFactory { private readonly ownership: FactoryOwnership /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } + private inboxProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') @@ -370,21 +318,16 @@ export class AgentLoop extends Service implements AgentFactory { maxParallelToolCalls: config.maxParallelToolCalls, } validateConfiguredAgents(this.config.agents) - // Register only after every config validation above has passed, so a - // rejected constructor leaves no projection unit behind. - const ownership = this.ownership = new FactoryOwnership(ctx.fiber) + this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } - ctx.effect(function* () { - const unregisterTurnBoundary = ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - yield unregisterTurnBoundary - const unregisterInbox = ctx.sessionProjections.register(inboxProjectionDefinition) - yield unregisterInbox - // Cordis skips later disposers after a rejection; finally still releases projections. - yield () => ownership.dispose().finally(() => { - unregisterInbox() - unregisterTurnBoundary() - }) - }, 'agentLoop.transactions()') + } + + protected async [Service.init](): Promise { + const ctx = this.runtime.ctx + const ownership = this.ownership + // The inbox remains readable until agent teardown finishes. + ctx.effect(() => () => ownership.dispose(), 'agentLoop.transactions()') + this.inboxProjectionHandle = await ctx.sessionProjections.register(inboxProjectionDefinition) ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') ctx.systemPrompt.variable('provider', context => context.agent?.options.provider) ctx.systemPrompt.variable('model', context => context.agent?.options.model) @@ -516,6 +459,7 @@ export class AgentLoop extends Service implements AgentFactory { : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason }) } const loopCtx = this.runtime.ctx + const lastTurn = loopCtx.agents.turnBoundaryOf(session).lastTurn // Deactivation fuses three owners, each with its own reason: the caller's // cancellation signal, the owner fiber's unload, and factory teardown. @@ -599,9 +543,10 @@ export class AgentLoop extends Service implements AgentFactory { } } const untrack = this.ownership.track(dispose) + const inboxProjectionHandle = this.inboxProjectionHandle try { unfollowOwner = ownerCtx.effect(function* () { - const created = machine = new ReactLoopAgent(loopCtx, id, options, session) + const created = machine = new ReactLoopAgent(loopCtx, id, options, session, () => inboxProjectionHandle.stateOf(session), lastTurn) machineReady.resolve() // Adopt the scope on its original fiber so it cannot unload before teardown. // A loop already unloading during minting owns the provisional scope itself. diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 739d18b990..ba908715e0 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -49,10 +49,10 @@ async function inboxAgent(rawId: string): Promise<{ onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const session = ctx.sessions.create(SessionId(rawId)) const agent = stubAgent(rawId, { ctx, session }) - const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + const inbox = new ReactLoopInbox(() => projection.stateOf(session), session, agentEvents(ctx, agent)) Object.assign(agent, { inbox }) return { ctx, session, agent, inbox } } @@ -67,9 +67,9 @@ async function reconstructPersistedInbox( const session = ctx.sessions.create(SessionId(rawId)) populate(session) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const agent = stubAgent(rawId, { ctx, session }) - const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + const inbox = new ReactLoopInbox(() => projection.stateOf(session), session, agentEvents(ctx, agent)) try { void inbox.nextTurn } catch (error: unknown) { @@ -80,12 +80,12 @@ async function reconstructPersistedInbox( } describe('ReactLoopInbox', () => { - it('reads the shared projection without owning its registration', async () => { + it('retains inbox reads after the shared registration is released', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - const unregister = ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const session = ctx.sessions.create(SessionId('inbox-projection')) const pending = createUserMessage({ content: [{ type: 'text', text: 'pending' }], @@ -96,8 +96,8 @@ describe('ReactLoopInbox', () => { }) const agent = stubAgent('inbox-projection', { ctx, session }) const dispatch = agentEvents(ctx, agent) - const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) - const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) + const first = new ReactLoopInbox(() => projection.stateOf(session), session, dispatch) + const second = new ReactLoopInbox(() => projection.stateOf(session), session, dispatch) expect(first.nextTurn).toEqual([pending]) expect(second.nextTurn).toEqual([pending]) @@ -106,9 +106,12 @@ describe('ReactLoopInbox', () => { 'next-step': [], }) - unregister() - expect(ctx.sessionProjections.stateOf(session, 'inbox')).toBeUndefined() - expect(() => first.nextTurn).toThrow('its projection registration is not active') + await projection.dispose() + expect(ctx.sessionProjections.checkpoint(session).inbox?.val).toBeUndefined() + expect(first.nextTurn).toEqual([pending]) + first.clear() + expect(second.nextTurn).toEqual([]) + expect(ctx.sessionProjections.snapshot(session).values.inbox).toBeUndefined() }) it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => { @@ -141,10 +144,10 @@ describe('ReactLoopInbox', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const parent = ctx.sessions.create(SessionId('inbox-fork-parent')) const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent }) - const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent)) + const parentInbox = new ReactLoopInbox(() => projection.stateOf(parent), parent, agentEvents(ctx, parentAgent)) const inherited = createUserMessage({ content: [{ type: 'text', text: 'parent pending' }], source: { kind: 'user' }, @@ -152,7 +155,7 @@ describe('ReactLoopInbox', () => { parentInbox.append('next-turn', inherited) const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child')) const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) - const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent)) + const childInbox = new ReactLoopInbox(() => projection.stateOf(child), child, agentEvents(ctx, childAgent)) expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length) expect(childInbox.nextTurn).toEqual([inherited]) @@ -175,7 +178,7 @@ describe('ReactLoopInbox', () => { let observed: readonly UserMessage[] | undefined ctx.on('session/event', (subject, event) => { if (subject === session && event.type === 'agent/inbox/spliced') { - observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn'] + observed = inbox.nextTurn } }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 9ae652df61..0dccc2a43b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk, expandAssistantStream } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -1912,6 +1912,7 @@ describe('agent loop', () => { defaultEffort: effort, }) const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -1923,6 +1924,7 @@ describe('agent loop', () => { }) ctx.llm.registerAdapter(['mock'], adapter) + await vi.waitFor(() => { expect(ctx.agents.list()).toHaveLength(1) }) const agent = ctx.agents.list()[0]! expect(agent).toBeDefined() expect(agent.id).toBe(agent.session.id) @@ -1940,6 +1942,7 @@ describe('agent loop', () => { it('attaches config agent cwd to the fresh session header', async () => { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -1950,6 +1953,7 @@ describe('agent loop', () => { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], }) + await vi.waitFor(() => { expect(ctx.agents.list()).toHaveLength(1) }) const agent = ctx.agents.list()[0]! expect(agent.session.header.cwd).toBe('/work/project') }) diff --git a/packages/core/agent-loop/tests/request-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index 560be88627..b0b4c22ff8 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -9,6 +9,7 @@ import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@ import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm' import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import * as values from '@deepseek-ai/dsh-util-values' +import { inboxProjectionDefinition } from '../src/inbox.ts' import { ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -73,7 +74,9 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(userEvent.data.content)).toBe(false) expect(Object.isFrozen(assistantEvent.data.message)).toBe(false) ctx.effect(() => ctx.sessions.enter(session)) - const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) + const inbox = await loopCtx.sessionProjections.register(inboxProjectionDefinition) + const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, + () => inbox.stateOf(session), ctx.agents.turnBoundaryOf(session).lastTurn) cleanups.push(async () => { agent.cancel({ kind: 'disposed' }) await agent.whenIdle() @@ -122,7 +125,8 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(session.deriveMessages())).toBe(false) expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1) expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1) - const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) + const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, + () => inbox.stateOf(session), ctx.agents.turnBoundaryOf(session).lastTurn) cleanups.push(async () => { resumed.cancel({ kind: 'disposed' }) await resumed.whenIdle() diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts index 8a0a66df12..eececf3f92 100644 --- a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -38,7 +38,6 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed const aborted = Promise.withResolvers() let workExited = false let scopeDisposedBeforeWorkExit = false - let projectionPresentAtWorkExit = false let scopeDisposed = false let abortHandled = 0 const controller = new AbortController() @@ -57,8 +56,6 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed const work = agent.runMaintenance(async (signal) => { signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) await release.promise - projectionPresentAtWorkExit = projections.stateOf(agent.session, 'inbox') !== undefined - && projections.stateOf(agent.session, 'turnBoundary') !== undefined workExited = true }) const disposal = loop.dispose() @@ -71,11 +68,10 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed await Promise.all([disposal, ownerDisposal]) for (const { handle, error } of failed) await expect(handle.dispose()).rejects.toBe(error) expect(scopeDisposedBeforeWorkExit).toBe(false) - expect(projectionPresentAtWorkExit).toBe(true) expect(scopeDisposed).toBe(true) expect(abortHandled).toBe(1) - expect(projections.stateOf(agent.session, 'inbox')).toBeUndefined() - expect(projections.stateOf(agent.session, 'turnBoundary')).toBeUndefined() + expect(projections.checkpoint(agent.session).inbox).toBeUndefined() + expect(projections.checkpoint(agent.session).turnBoundary).toBeDefined() expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.session.id)).toBeUndefined() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2addf9cba2..02d84f2fba 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -288,7 +288,7 @@ describe('agent scope lifecycle', () => { const { ctx, loopFiber } = await harnessWithLoop() onTestFinished(() => ctx.fiber.dispose()) const cold = ctx.sessions.create(SessionId('projection-before-any-agent')) - expect(ctx.sessionProjections.stateOf(cold, 'inbox')).toEqual({ 'next-turn': [], 'next-step': [] }) + expect(ctx.sessionProjections.checkpoint(cold).inbox?.val).toEqual({ 'next-turn': [], 'next-step': [] }) let first!: Awaited> let second!: Awaited> const firstOwner = await ctx.plugin(Object.assign(async (inner: Context) => { @@ -304,15 +304,15 @@ describe('agent scope lifecycle', () => { }) }, { inject: ['agents'] })) - expect(ctx.sessionProjections.stateOf(first.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(first.agent.session).inbox?.val).toBeDefined() await firstOwner.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined() await secondOwner.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined() await Promise.all([first.dispose(), second.dispose()]) await loopFiber.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeUndefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeUndefined() }) it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { @@ -567,7 +567,7 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => { + it('factory unload during scope minting drains provisional setup and cleanup', async () => { const { ctx, loopFiber } = await harnessWithLoop() let unloaded = false let setupCalls = 0 diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index befdc09642..8956e55244 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-agent/use-this-package: - en: b48322540e229083 - zh: 05aec0854397f4c3 + en: 197d9f5c8671d103 + zh: c622822905725d5f /deepseek-ai-dsh-agent/use-this-package/create-or-resume-an-agent: en: e305441a5a4a9b44 zh: 3e997b12ce0ce04c @@ -39,8 +39,8 @@ en: 348b9ecd0890c585 zh: d0f73d28d0d2c6f1 /deepseek-ai-dsh-agent/understand-the-implementation/durable-inbox: - en: f6802c7f94f623b3 - zh: cc3885db80765970 + en: 8f3fe9303468d735 + zh: 52162010ae1ed29e /deepseek-ai-dsh-agent/understand-the-implementation/source-map: en: 7e09b94c79dc97ca zh: 3f9c5455c4dc563a diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9da1f06f64..5678895970 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -25,7 +25,7 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu ## Use this package -Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both. +Mount `dsh-agent` with `dsh-session-projection`: it provides `ctx.agents`, the `Agent` handle, and `turnBoundaryOf(session)` for recorded turn and step positions. Creating or resuming an Agent also requires a driver to register a factory; the shipped driver is `dsh-agent-loop`. ### Create or resume an agent @@ -86,7 +86,7 @@ The package is built on one separation: the public `Agent` surface and registry ### Durable inbox -`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; constructing its concrete inbox ensures that the projection registry owns one registration for the durable `agent/inbox/spliced` fold. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq. +`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; the loop service acquires one registration for the durable `agent/inbox/spliced` fold and gives each inbox a state-reading callback. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq. `Inbox` exposes pending `nextTurn` and `nextStep` messages and mutates them through `append`, `prepend`, `replace`, `remove`, `clear`, and `splice`. Ordinary removals and `clear()` are durable cancellations. At a step boundary, the loop's internal implementation claims pending input through pure deletion splices. Live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 0166f96a80..cd394d7657 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。 +将 `dsh-agent` 与 `dsh-session-projection` 一起挂载:它提供 `ctx.agents`、`Agent` 句柄,以及读取已记录轮次和步骤位置的 `turnBoundaryOf(session)`。创建或恢复 Agent 还需要驱动器注册工厂;随附驱动器是 `dsh-agent-loop`。 ### 创建或恢复 agent @@ -86,7 +86,7 @@ await handle.agent.whenIdle() ### 持久 inbox -`Agent.inbox` 只暴露结构型 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;构造具体 inbox 时会确保投影注册表为持久 `agent/inbox/spliced` fold 持有一份注册。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。 +`Agent.inbox` 只暴露结构型 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;loop 服务为持久 `agent/inbox/spliced` fold 获取一份注册,并向每个 inbox 提供状态读取回调。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。 `Inbox` 暴露待处理的 `nextTurn` 与 `nextStep` 消息,并通过 `append`、`prepend`、`replace`、`remove`、`clear` 与 `splice` 变更它们。普通删除和 `clear()` 都是持久取消。在步骤边界,循环的内部实现会通过纯删除 splice 领取待处理输入。实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 08d708f96e..4346e73f12 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -60,5 +60,9 @@ "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" + }, + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:*", + "zod": "4.4.3" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index dd8d09e6b7..d1515665e4 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -11,10 +11,12 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { installTurnArchiveAdmission } from './archive-admission.ts' -import type { Agent } from './types.ts' +import type { Agent, TurnBoundaryProjection } from './types.ts' import type { AgentOptions, SessionStartSource } from './runtime-types.ts' +import { turnBoundaryProjectionDefinition } from './projection.ts' export * from './runtime-types.ts' export * from './types.ts' @@ -243,6 +245,9 @@ interface FactorySlot { * nested lineage that starts an owning-fiber unload is excluded from its own drain. */ export class AgentRegistry extends Service { + static inject = ['sessionProjections'] + + private turnBoundaryProjectionHandle!: ProjectionHandle private store = new Map() private factory: FactorySlot | undefined private readonly initiators = new AsyncLocalStorage() @@ -284,6 +289,19 @@ export class AgentRegistry extends Service { installTurnArchiveAdmission(ctx, sessionId => this.get(sessionId)) } + protected async [Service.init](): Promise { + this.turnBoundaryProjectionHandle = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + } + + /** + * Read the turn and step positions recorded in a Session. + * @param session - Session whose committed events are read. + * @returns the current turn and step positions; do not mutate the returned value. + */ + turnBoundaryOf(session: Session): TurnBoundaryProjection { + return this.turnBoundaryProjectionHandle.stateOf(session) + } + /** * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution diff --git a/packages/core/agent/src/projection.ts b/packages/core/agent/src/projection.ts index 89c10ac858..a3414561c5 100644 --- a/packages/core/agent/src/projection.ts +++ b/packages/core/agent/src/projection.ts @@ -1,11 +1,59 @@ +/** Shared pure fold of the Session's turn and step events. */ import type { TurnBoundaryProjection } from './types.ts' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import { brandNumber } from '@deepseek-ai/dsh-brand' +import { z as zod } from 'zod' -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** The agent session's open/last turn and step boundary facts (whole value). */ - turnBoundary: TurnBoundaryProjection - } -} +const sessionSeqSchema = zod.number().int().nonnegative().transform(brandNumber) -export {} +const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ + openTurnStartSeq: sessionSeqSchema.nullable(), + lastStepStartSeq: sessionSeqSchema.nullable(), + lastStepBoundary: zod.object({ + kind: zod.union([zod.literal('start'), zod.literal('end')]), + seq: sessionSeqSchema, + }).nullable(), + lastTurn: zod.number().int().nonnegative(), +}) + +/** Host projection of agent turn and step boundaries. */ +export const turnBoundaryProjectionDefinition = { + key: 'turnBoundary', + stateVersion: 2, + stateSchema: turnBoundaryProjectionSchema, + init: () => ({ + openTurnStartSeq: null, + lastStepStartSeq: null, + lastStepBoundary: null, + lastTurn: 0, + }), + apply: (state, event) => { + switch (event.type) { + case 'turn/start': + return { + ...state, + openTurnStartSeq: event.seq, + lastTurn: event.data.turn, + } + case 'turn/end': + return { + ...state, + openTurnStartSeq: null, + } + case 'step/start': + return { + ...state, + lastStepStartSeq: event.seq, + lastStepBoundary: { kind: 'start', seq: event.seq }, + } + case 'step/end': + return { + ...state, + lastStepBoundary: { kind: 'end', seq: event.seq }, + } + default: + return state + } + }, +} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 5cbc81df38..dc54eb6737 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -56,25 +56,13 @@ export interface InboxWireState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Pending agent input reconstructed from durable inbox splices. */ - inbox: InboxState - } interface SessionProjectionMap { /** Pending agent input reconstructed from durable inbox splices. */ inbox: InboxWireState } } -/** - * Turn and step boundaries folded from one agent session log. - * - * Reader contract: the key is registered by `dsh-agent-loop` and absent - * otherwise. Without agent-loop no turn events exist, so readers treat an - * absent key as "no open turn / no boundaries" — capability absence, not a - * corrupt state. A reader whose behavior has no safe fallback for that - * absence (the step-open decision, for example) may fail loud instead. - */ +/** Turn and step positions recorded in a Session. */ export interface TurnBoundaryProjection { /** Seq of the open turn's `turn/start`, or null between turns. */ readonly openTurnStartSeq: OptionalSessionSeq diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts index c86e0edfcf..7b7a5a13bb 100644 --- a/packages/core/agent/tests/agent-initiator.spec.ts +++ b/packages/core/agent/tests/agent-initiator.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { runInNewContext } from 'node:vm' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' @@ -15,6 +16,7 @@ async function harness(): Promise<{ dispose: () => Promise }> { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(AgentRegistry) return { ctx, diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 21d90151cb..39516873f0 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,7 +1,8 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' +import { describe, expect, expectTypeOf, it, onTestFinished } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { @@ -39,8 +40,33 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { } describe('AgentRegistry', () => { + it('waits for its projection owner and reads turn positions without a loop', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const fiber = ctx.plugin(AgentRegistry) + expect(ctx.get('agents')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + await fiber + const session = Session.create(SessionId('turn-positions')) + expect(ctx.agents.turnBoundaryOf(session)).toEqual({ + openTurnStartSeq: null, lastStepStartSeq: null, lastStepBoundary: null, lastTurn: 0, + }) + const turn = session.append('turn/start', { turn: 3 }) + const step = session.append('step/start', { turn: 3, step: 1 }) + expect(ctx.agents.turnBoundaryOf(session)).toEqual({ + openTurnStartSeq: turn.seq, lastStepStartSeq: step.seq, + lastStepBoundary: { kind: 'start', seq: step.seq }, lastTurn: 3, + }) + session.append('turn/end', { turn: 3, reason: { kind: 'completed' } }) + expect(ctx.agents.turnBoundaryOf(session)).toMatchObject({ openTurnStartSeq: null, lastTurn: 3 }) + expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined() + await fiber.dispose() + expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeUndefined() + }) + it('contributes Agent lookup and scoped Context providers while Typert is live', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const agentFiber = ctx.plugin(AgentRegistry) await agentFiber await ctx.plugin(TypertRegistry) @@ -69,6 +95,7 @@ describe('AgentRegistry', () => { it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) @@ -88,6 +115,7 @@ describe('AgentRegistry', () => { it('rejects an agent whose registry and session identities differ', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const agent = stubAgent('agent-id', { session: Session.create(SessionId('session-id')) }) @@ -98,6 +126,7 @@ describe('AgentRegistry', () => { it('tracks runtime creator ownership separately from registry order', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const root = stubAgent('root') const child = stubAgent('child') @@ -119,6 +148,7 @@ describe('AgentRegistry', () => { it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) @@ -132,6 +162,7 @@ describe('AgentRegistry', () => { it('rolls back asynchronous creation rejection and contains every disposal-listener failure', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const warnings: string[] = [] const heard: string[] = [] @@ -155,6 +186,7 @@ describe('AgentRegistry', () => { it('awaits each creation listener before the next listener and registration completion', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const entered = Promise.withResolvers() const release = Promise.withResolvers() @@ -185,6 +217,7 @@ describe('AgentRegistry', () => { it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) @@ -209,6 +242,7 @@ describe('AgentRegistry', () => { it('defers detach requested by a creation listener until that dispatch unwinds', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const order: string[] = [] const agent = stubAgent('reentrant') @@ -304,6 +338,7 @@ describe('AgentRegistry factory seam', () => { it('requires a factory and delegates through the calling context', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) const { factory, calls } = stubFactory() @@ -323,6 +358,7 @@ describe('AgentRegistry factory seam', () => { it('keeps the runtime parent in options separately from the caller context', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) @@ -337,6 +373,7 @@ describe('AgentRegistry factory seam', () => { it('rejects a second factory and clears the slot with its owner (HMR)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const owner = await ctx.plugin(Object.assign((inner: Context) => { inner.agents.setFactory(stubFactory().factory) @@ -349,6 +386,7 @@ describe('AgentRegistry factory seam', () => { it('canonicalizes an already traced Service before tracing it for the caller', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const states = new WeakMap() class TracedFactory extends Service implements AgentFactory { diff --git a/packages/core/agent/tests/archive-admission.spec.ts b/packages/core/agent/tests/archive-admission.spec.ts index 0f84de4fe0..8813d4e66f 100644 --- a/packages/core/agent/tests/archive-admission.spec.ts +++ b/packages/core/agent/tests/archive-admission.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentCancelCause, AgentStatus, CancelOptions } from '@deepseek-ai/dsh-agent' import type { SessionActivity } from '@deepseek-ai/dsh-workspace' @@ -46,6 +47,7 @@ function stop(ctx: Context, sessionId: SessionId): Promise { describe('Turn archive admission', () => { it('reports the turn family only while the session\'s Agent is running, ahead of later providers', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const agent = await liveAgent(ctx, 'busy', 'running') await liveAgent(ctx, 'quiet', 'idle') @@ -61,6 +63,7 @@ describe('Turn archive admission', () => { it('cancels a running turn with the user cause and without keeping the inbox, and leaves the rest alone', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const running = await liveAgent(ctx, 'stop-me', 'running') const idle = await liveAgent(ctx, 'already-idle', 'idle') @@ -74,6 +77,7 @@ describe('Turn archive admission', () => { it('stops answering once the registry fiber is disposed', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(AgentRegistry) const agent = await liveAgent(ctx, 'watched', 'running') expect(await ask(ctx, agent.id)).toEqual([{ kind: 'turn' }]) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index b597fc7068..a84b932abe 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../core/scope" }, diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json index a07552caa4..85391b8ad9 100644 --- a/packages/deliverables/tool-present/package.json +++ b/packages/deliverables/tool-present/package.json @@ -42,7 +42,6 @@ "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index 8b47748b03..41edef2e12 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -5,7 +5,6 @@ import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-projection' import type { Session } from '@deepseek-ai/dsh-session' import type { PresentedFile } from './types.ts' @@ -24,7 +23,7 @@ export const Config: z = z.object({ }) /** Services used by the scoped delivery tool. */ -export const inject = ['tools', 'fs', 'sessionProjections', 'workingDirectory'] +export const inject = ['tools', 'fs', 'agents', 'workingDirectory'] /** * Register present with durable file references in its tool result. @@ -76,8 +75,8 @@ export function apply(ctx: Context, config: Config): void { }, async execute(args, exec) { if (exec.agent === undefined) throw new Error('present requires an agent Session') - const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary') - if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') + const boundary = ctx.agents.turnBoundaryOf(exec.agent.session) + if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal) const options = { cwd, signal: exec.signal } diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index e8b0bee060..2fe7201def 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -17,6 +17,7 @@ import SystemPrompt from './packages/core/system-prompt/lib/index.js' import ToolRuntime from './packages/core/tools/lib/index.js' import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' import * as Present from './packages/deliverables/tool-present/lib/index.js' +import SessionProjectionRegistry from './packages/session/session-projection/lib/index.js' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -28,10 +29,11 @@ try { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) - ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(Present, { maxFiles: 2 }) const scope = ctx.plugin(() => {}) const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false }) + session.append('turn/start', { turn: 1 }) const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' } await ctx.agents.register(owner) const events = [] diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index afbbf5958a..10d1221db4 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -11,7 +11,6 @@ import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools' import type { PresentedFile } from '../src/types.ts' @@ -59,10 +58,9 @@ async function setup() { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) - await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const fiber = ctx.plugin(Present, { maxFiles: 2 }) await fiber const owner = await agent(ctx, root) @@ -149,9 +147,12 @@ describe('present', () => { }) -it('validates deployment limits before registering the tool', () => { +it('validates deployment limits before registering the tool', async () => { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await ctx.plugin(SessionProjectionRegistry) for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) { - expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles') + expect(() => { Present.apply(ctx, config) }).toThrow('positive integer maxFiles') } }) diff --git a/packages/deliverables/tool-present/tsconfig.json b/packages/deliverables/tool-present/tsconfig.json index 0e428995b8..24ab42a2f7 100644 --- a/packages/deliverables/tool-present/tsconfig.json +++ b/packages/deliverables/tool-present/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../../core/session" }, - { - "path": "../../session/session-projection" - }, { "path": "../../core/tools" }, diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 116f3a9ec4..2f9ac3db9b 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -18,8 +18,8 @@ en: b55aeb927737b90d zh: 0b215fcb5c2c7908 /packages-experimental/packages: - en: 372b182dbf0f662b - zh: 7a8031c506394785 + en: b16e94185c9b8b24 + zh: 784faa79e027cd14 /packages-experimental/related-documentation: en: 1cb9d3448d44e48d zh: cd326d70a746b896 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index d8ccf0dfaa..ba4fabaddf 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -69,8 +69,9 @@ Promotion requires a named product role, documented public behavior and limitati | [`claude-code-mods`](claude-code-mods/README.md) | Run Claude Code mods as plugins: their hook chains on harness extension points and a band above the prompt | `ctx.claudeCodeMods` | | [`client-ui-claude-code-mods`](client-ui-claude-code-mods/README.md) | The Web band that draws a mod's tree above the prompt and sends button clicks back | — | | [`ptc-runtime-python`](ptc-runtime-python/README.md) | CPython subprocess backend for the PTC execution seam | `ctx.ptcRuntime` | -| [CUA](cua/README.md) | Shared application/window objects, consent, and logged execution. | Library | +| [CUA](cua/README.md) | Application/window objects, authorized execution, and permission controls. | `ctx.cuaAccess` | | [macOS CUA](cua-macos/README.md) | Swift native execution and platform preparation. | `ctx.computerUse` | +| [`cua-bundle`](cua-bundle/README.md) | Optional Computer Use switch for PTC sessions | — | | [`computer-use-cua-driver-mcp`](computer-use-cua-driver-mcp/README.md) | Use an installed Cua Driver through MCP | `ctx.computerUse` | | [`computer-use-cua-driver-native`](computer-use-cua-driver-native/README.md) | Embed the Cua Driver native npm runtime | `ctx.computerUse` | | [`browser-use-playwright-mcp`](browser-use-playwright-mcp/README.md) | Playwright browser tools over MCP | `ctx.browserUse` | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 35b805ef06..eb99c614fb 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -69,8 +69,9 @@ kind: "package-group" | [`claude-code-mods`](claude-code-mods/README.zh.md) | 把 Claude Code 模组作为插件运行:钩子链落在 harness 扩展点上,并在提示框上方绘制横幅 | `ctx.claudeCodeMods` | | [`client-ui-claude-code-mods`](client-ui-claude-code-mods/README.zh.md) | 在提示框上方绘制模组树并把按钮点击发回的 Web 横幅 | — | | [`ptc-runtime-python`](ptc-runtime-python/README.zh.md) | PTC 执行 seam 的 CPython 子进程后端 | `ctx.ptcRuntime` | -| [CUA](cua/README.zh.md) | 共享应用与窗口对象、授权和已记录的执行。 | 库 | +| [CUA](cua/README.zh.md) | 应用与窗口对象、授权执行和权限控件。 | `ctx.cuaAccess` | | [macOS CUA](cua-macos/README.zh.md) | Swift 原生执行与平台组件准备。 | `ctx.computerUse` | +| [`cua-bundle`](cua-bundle/README.zh.md) | 面向 PTC 会话、默认关闭的 Computer Use 开关 | — | | [`computer-use-cua-driver-mcp`](computer-use-cua-driver-mcp/README.zh.md) | 通过 MCP 使用已安装的 Cua Driver | `ctx.computerUse` | | [`computer-use-cua-driver-native`](computer-use-cua-driver-native/README.zh.md) | 嵌入 Cua Driver 原生 npm 运行时 | `ctx.computerUse` | | [`browser-use-playwright-mcp`](browser-use-playwright-mcp/README.zh.md) | 通过 MCP 提供 Playwright 浏览器工具 | `ctx.browserUse` | diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 017a9d603e..2660b01fa7 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -63,14 +63,14 @@ en: 6922e307e18696b4 zh: 083f4d95587a844b /deepseek-ai-dsh-experimental-agent-team/understand-the-implementation/disposal: - en: a12fb358183cc6d8 - zh: 7fdb0434d3c407fe + en: f32086839d37c9a6 + zh: b40ab7140b314afb /deepseek-ai-dsh-experimental-agent-team/further-exploration: en: 067ed50203315774 zh: 7b4dfd41eea2208b /deepseek-ai-dsh-experimental-agent-team/further-exploration/browser-projection: - en: 4b3033806e6db528 - zh: 53e0dfef7309840e + en: 5dfc9113cabec7cd + zh: 22eaee731ca3f1b3 /deepseek-ai-dsh-experimental-agent-team/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 69d602f079..8b8bf9a3c7 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -149,7 +149,7 @@ Mailbox projection and checkpoint admission preserve every decoded JSON field of ### Disposal -The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported. +The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported. Team shutdown is independent of projection unregistration; its retained reader follows Session events while admitted work settles. @@ -171,7 +171,7 @@ Read these pages when the package-level contract is not enough. They move from t ### Browser projection -The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. +The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. A failed projection rejects subsequent Team reads and mutations without appending further Team events. The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 37bcdafded..f68940e7e1 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -149,7 +149,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内 ### Dispose -运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。 +运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。 Team 停机与投影注销独立;在已获准操作收尾期间,保留的 reader 仍会根据 Session 事件更新读取结果。 @@ -171,7 +171,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内 ### 浏览器投影 -`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 +`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 投影失败后,后续 Team 读取和修改均报错,不再追加 Team 事件。 [Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 2d35933776..c0da4c9a24 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -68,9 +68,9 @@ export class TeamService extends Service { private readonly activity: TeamActivity private readonly lifecycle: TeamRuntimeLifecycle - private readonly journal: TeamJournal - private readonly roster: TeamRoster - private readonly tasks: TeamTaskBoard + private journal!: TeamJournal + private roster!: TeamRoster + private tasks!: TeamTaskBoard constructor(ctx: Context, config: Config = {}) { super(ctx, 'agentTeams') @@ -86,25 +86,21 @@ export class TeamService extends Service { this.activity = new TeamActivity() this.lifecycle = new TeamRuntimeLifecycle(this.config.disposalTimeoutMs) - this.journal = new TeamJournal(ctx, (root) => { this.activity.notify(TeamId(root.id)) }) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + const teamProjectionHandle = await ctx.sessionProjections.register(teamProjectionDefinition) + this.journal = new TeamJournal(ctx, teamProjectionHandle, (root) => { this.activity.notify(TeamId(root.id)) }) this.roster = new TeamRoster(ctx, this.journal, this.lifecycle, this.config.maxMembers) this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks) + ctx.effect(() => () => this.disposeRuntime(), 'agentTeams.runtimeLifecycle()') ctx.on('agent/created', ({ agent }) => { this.scheduleRecovery(agent) }) ctx.on('agent/status', ({ agent }) => { const membership = this.roster.tryMembership(agent) if (membership !== undefined) this.activity.notify(membership.id) }) - ctx.effect(() => { - const disposeProjection = ctx.root.sessionProjections.register(teamProjectionDefinition) - return async () => { - try { - await this.disposeRuntime() - } finally { - disposeProjection() - } - } - }, 'agentTeams.runtimeLifecycle()') for (const agent of ctx.agents.list()) this.scheduleRecovery(agent) } diff --git a/packages/experimental/agent-team/src/journal.ts b/packages/experimental/agent-team/src/journal.ts index f69ac376b8..d57fcca30b 100644 --- a/packages/experimental/agent-team/src/journal.ts +++ b/packages/experimental/agent-team/src/journal.ts @@ -1,9 +1,10 @@ /** Serialized Team transactions over the exact live Lead Session log. */ import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session' -import type { TeamEventType, TeamState } from './projection.ts' +import type { TeamEventType, TeamProjectionState, TeamState } from './projection.ts' type AppendTeamEvent = (type: T, data: SessionEventMap[T]) => void type MutableTeamEventType = 'team/member' | 'team/task' @@ -14,10 +15,12 @@ export class TeamJournal { /** * @param ctx - Team service context with the injected Session service. + * @param teamProjectionHandle - Team state registration owned by the Team service. * @param onCommit - synchronous notification after the Team event flush succeeds. */ constructor( private readonly ctx: Context, + private readonly teamProjectionHandle: ProjectionHandle, private readonly onCommit: (root: Agent) => void, ) {} @@ -25,10 +28,10 @@ export class TeamJournal { * Read authoritative Team state for one exact live Lead. * @param root - exact live Team Lead. * @returns current projected state selected by the Lead Team id. + * @throws when the durable Team projection has failed. */ state(root: Agent): TeamState { - const projection = this.ctx.sessionProjections.stateOf(root.session, 'agentTeam') - if (projection === undefined) throw new Error('Agent Teams projection is not registered') + const projection = this.teamProjectionHandle.stateOf(root.session) if (projection.failure !== undefined) throw new Error(projection.failure) return projection } diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index bc9239d3d8..dc02db2298 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -158,12 +158,6 @@ export interface TeamProjectionState extends TeamState { readonly failure?: string } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - agentTeam: TeamProjectionState - } -} - const teamProjectionEntrySchema = z.object({ id: teamIdSchema, members: z.array(teamMemberSnapshotSchema), diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 2266b41483..2de9c75ff4 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -90,7 +90,7 @@ describe('Agent Teams projection events', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + await ctx.sessionProjections.register(teamProjectionDefinition) const session = ctx.sessions.create(ROOT) const restore = (val: unknown) => ctx.sessionProjections.restore({ agentTeam: { ver: teamProjectionDefinition.stateVersion, seq: -1, val }, @@ -359,7 +359,7 @@ describe('Agent Teams projection events', () => { const registry = ctx.plugin(SessionProjectionRegistry) try { await registry - ctx.sessionProjections.register(teamProjectionDefinition) + await ctx.sessionProjections.register(teamProjectionDefinition) const extension = JSON.parse('{"type":"plugin/custom","__proto__":{"saved":true}}') as ContentBlock const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message({ content: [extension] }), @@ -546,7 +546,7 @@ describe('Agent Teams projection events', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + await ctx.sessionProjections.register(teamProjectionDefinition) const published: Array<{ session: SessionId; key: string; value: unknown }> = [] ctx.sessionProjections.onChanged((session, key, value) => { published.push({ session: session.id, key, value }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 34a9a25afd..b9da455b78 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -1,3 +1,4 @@ +import { Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -141,19 +142,20 @@ async function waitRunning(ctx: Context, id: SessionId): Promise { } describe('Team identity and provisioning', () => { - it('rejects missing and failed authoritative Team projections', async () => { - const first = await setup([]) - const journal = teamInternals(first.ctx).journal - const stateOf = first.ctx.sessionProjections.stateOf.bind(first.ctx.sessionProjections) - const stateOfSpy = vi.spyOn(first.ctx.sessionProjections, 'stateOf').mockImplementation((session, key) => ( - key === 'agentTeam' ? undefined : stateOf(session, key) - )) - expect(() => journal.state(first.lead)).toThrow('Agent Teams projection is not registered') - stateOfSpy.mockImplementation((session, key) => key === 'agentTeam' - ? { ...teamProjectionDefinition.init(session.header), failure: 'failed Team projection' } - : stateOf(session, key)) - expect(() => journal.state(first.lead)).toThrow('failed Team projection') - stateOfSpy.mockRestore() + it('rejects reads and mutations after a Team projection fails', async () => { + const { ctx, lead } = await setup([]) + lead.session.append('team/message/delivered', { + version: 2, + teamId: TeamId(lead.id), + messageId: TeamMessageId('unqueued-message'), + targetId: lead.id, + }) + + expect(() => ctx.agentTeams.listMembers(lead)).toThrow('delivered before queueing') + const eventCount = lead.session.snapshotEvents().length + await expect(ctx.agentTeams.createTask(lead, { subject: 'blocked', description: 'blocked' })) + .rejects.toThrow('delivered before queueing') + expect(lead.session.snapshotEvents()).toHaveLength(eventCount) }) it('rejects deployment limits that are not positive safe integers', async () => { @@ -181,7 +183,11 @@ describe('Team identity and provisioning', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const lead = await ctx.agentLoop.create(SessionId('preexisting-lead'), {}) - const service = new TeamService(ctx) + class DirectTeamService extends TeamService { + initialize(): Promise { return this[Service.init]() } + } + const service = new DirectTeamService(ctx) + await service.initialize() expect(service.listMembers(lead)).toEqual([expect.objectContaining({ name: 'lead', @@ -1147,6 +1153,32 @@ describe('Team direct messages and waiting', () => { expect(ctx.get('agentTeams')).toBeUndefined() }) + it.each([false, true])('settles admitted work after projection unregistration (failure: %s)', async (fails) => { + const { ctx, lead, teamFiber } = await setup([]) + const internal = teamInternals(ctx) + const state = internal.journal.state(lead) + const finish = Promise.withResolvers() + let readDuringShutdown = false + void internal.lifecycle.track(finish.promise.then(() => { + expect(internal.journal.state(lead)).toBe(state) + readDuringShutdown = true + if (fails) throw new Error('admitted operation failed during shutdown') + })) + const disposal = teamFiber.dispose() + try { + await vi.waitFor(() => { + expect(internal.lifecycle.disposed).toBe(true) + expect(ctx.sessionProjections.checkpoint(lead.session)).not.toHaveProperty('agentTeam') + }) + expect(readDuringShutdown).toBe(false) + } finally { + finish.resolve(undefined) + await disposal + } + expect(readDuringShutdown).toBe(true) + expect(ctx.get('agentTeams')).toBeUndefined() + }) + it('closes creation admission and drains an in-flight spawn before unload completes', async () => { const { ctx, lead, teamFiber } = await setup(['hang']) const service = ctx.agentTeams diff --git a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts index d9c4465447..fdb9a6a749 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts @@ -148,6 +148,7 @@ it('waits for SystemPrompt and ToolRuntime before reserving the provider', async const ctx = new Context() contexts.push(ctx) await ctx.plugin(BrowserUse) + await ctx.plugin(Projections) await ctx.plugin(Agents) provideWorkingDirectoryFixture(ctx) const tools = ctx.plugin(Tools) diff --git a/packages/experimental/browser-use-runtime/tests/resources.spec.ts b/packages/experimental/browser-use-runtime/tests/resources.spec.ts index f838191d44..7fadb86163 100644 --- a/packages/experimental/browser-use-runtime/tests/resources.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/resources.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -11,6 +12,7 @@ const contexts: Context[] = [] async function fixture() { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) async function owner(id: string) { const fiber = ctx.plugin(() => {}) diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml index cf54b15b22..bf0aada73f 100644 --- a/packages/experimental/claude-code-mods/README.i18n.yaml +++ b/packages/experimental/claude-code-mods/README.i18n.yaml @@ -9,8 +9,8 @@ en: bf54b493565f1624 zh: 6f28a518f7b0dea8 /deepseek-ai-dsh-experimental-claude-code-mods/summary: - en: 4ddb71e280b6b328 - zh: e4775593fd7f232c + en: 2b8cee860a1ada68 + zh: 185cf00e144270dc /deepseek-ai-dsh-experimental-claude-code-mods/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -24,8 +24,8 @@ en: 2b04be6b58e61a74 zh: f4854198d8999d11 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-members-your-mod-can-call: - en: bf4f9f971aeb1d17 - zh: 83804163ca9039d5 + en: 563ff4127a48a194 + zh: 2acf06dfcfcc6457 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/test-a-mod: en: 5848fccf79f41e0a zh: 62bb20c4d7e4d8c2 @@ -78,8 +78,8 @@ en: ca31263f47f654d9 zh: 17d55e6641705627 /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work: - en: 99aaa73c8ef9c609 - zh: 46fab480bd2b1706 + en: 42cdaecb2a072efe + zh: 09419c1d94f3d95a /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work/dev-note: en: 4d1e26f4cc9c3c1c zh: d80209b56c56b11c diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md index 292a5916c4..24f1f0c268 100644 --- a/packages/experimental/claude-code-mods/README.md +++ b/packages/experimental/claude-code-mods/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. +Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory` and `dsh-agent`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. ## Table of Contents @@ -87,7 +87,7 @@ The band is one instance per session, drawn by the first mod in load order whose | `$.command` | `register`, `run`, `list` | `ctx.commands`, scoped to the agent whose event is running | | `$.tool` | `register`, `call`, `list` | `ctx.tools`; a registered tool is named `mcp____`; contexts a tool defers to the next request are injected into the session | | `$.prompt` | `submit` | `agent.followup()` as a `user`-sourced message, framed as a message from the mod unless `asUser`; the `prompt.submit` it raises carries `origin: { kind: 'plugin', name }` | -| `$.session` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version` | the agent's Session and the `turnBoundary` and `contextPressure` projections; `cwd` reports the committed current directory through `ctx.workingDirectory`; `root` keeps the original project | +| `$.session` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version` | the agent's Session, `agents.turnBoundaryOf()`, and optional `tokenMeter.contextPressure()`; `cwd` reports the committed current directory through `ctx.workingDirectory`; `root` keeps the original project | | `$.state` | `get`, `set` | memory held for the session, addressed by the `{ plugin, key }` a mod names; a read during `ui.render` subscribes the band | | `$.store` | `get`, `set`, `delete`, `keys` | the `claude_code_mods` storage domain, one JSON object per plugin, 4 MiB | | `$.clock` | `now`, `sleep`, `after`, `every` | timers owned by the session whose event scheduled them; `sleep` rejects when the event is cancelled | @@ -206,7 +206,7 @@ These limits describe where a Claude Code mod behaves differently through this b - **One process, many sessions** — a mod's module-level variables and `$.env.set` writes are shared by every session and plugin in the process, where Claude Code runs one session per process; keep per-session values in `$.state`. `session.start` and `session.end` fire for root agents only. - **No sandbox, no static analysis, no hot reload** — the hooks module runs in-process with Node's globals and the process's full authority (its environment, network, filesystem, and tools); the `$`-only access rule, `claude plugin validate`, type generation, `--plugin-dir` watching, and the in-session mod authoring flow are not implemented. Mount only mods you would run as a plugin. Mounting a mod again re-runs `register` on the same evaluated module, so module-level variables keep their values. - **`turn.complete` text** — the `{ text }` a hook returns reaches the host log, not a line under the answer; `durationMs` counts from the turn's `turn/start`. -- **`$.session.usage`** — `window` is `0` and `percent` absent until the route's context window and a provider usage report are known through the token meter; `rateLimits` is always empty. `$.fs.stat` reports `mtimeMs: 0`. +- **`$.session.usage`** — `window` is `0` and `percent` absent until the route's context window and a provider usage report are known through the token meter; without the token-meter service, token counts are absent and `window` is `0`. `rateLimits` is always empty. `$.fs.stat` reports `mtimeMs: 0`. - **`plugin.json` and `hooks.json`** — not read; `defineMod` carries the identity, and a plugin's settings hooks need `@deepseek-ai/dsh-hooks-claude-code`. diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md index c8cbc7163c..6ed7992f33 100644 --- a/packages/experimental/claude-code-mods/README.zh.md +++ b/packages/experimental/claude-code-mods/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 +在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory` 和 `dsh-agent`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 ## 目录 @@ -87,7 +87,7 @@ Blast Radius 的 DSH 包装使用当前运行的可执行文件提供 Node 计 | `$.command` | `register`、`run`、`list` | `ctx.commands`,限定在事件所属的智能体 | | `$.tool` | `register`、`call`、`list` | `ctx.tools`;注册的工具名为 `mcp____`;工具推迟到下一请求的上下文会注入会话 | | `$.prompt` | `submit` | `agent.followup()`,作为 `user` 来源的消息,除非 `asUser` 否则套上"来自模组"的框架;它触发的 `prompt.submit` 带 `origin: { kind: 'plugin', name }` | -| `$.session` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version` | 智能体的 Session 及 `turnBoundary`、`contextPressure` 投影;`cwd` 通过 `ctx.workingDirectory` 报告已提交的当前目录;`root` 保留原始项目 | +| `$.session` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version` | 智能体的 Session、`agents.turnBoundaryOf()` 和可选的 `tokenMeter.contextPressure()`;`cwd` 通过 `ctx.workingDirectory` 报告已提交的当前目录;`root` 保留原始项目 | | `$.state` | `get`、`set` | 为会话持有的内存,以模组命名的 `{ plugin, key }` 寻址;`ui.render` 期间的读取会让横幅订阅 | | `$.store` | `get`、`set`、`delete`、`keys` | `claude_code_mods` 存储域,每插件一个 JSON 对象,4 MiB | | `$.clock` | `now`、`sleep`、`after`、`every` | 由安排它们的事件所属会话拥有的定时器;事件取消时 `sleep` 拒绝 | @@ -206,7 +206,7 @@ Message from the "" mod: - **一个进程,多个会话** — 模组的模块级变量与 `$.env.set` 写入由进程内所有会话与插件共享,而 Claude Code 每进程一个会话;按会话的值请放在 `$.state`。`session.start` 与 `session.end` 只为根智能体触发。 - **无沙箱、无静态分析、无热重载** — 钩子模块以 Node 全局对象在进程内运行,拥有本进程的全部权限(环境变量、网络、文件系统与工具);仅 `$` 访问规则、`claude plugin validate`、类型生成、`--plugin-dir` 监视与会话内模组编写流程均未实现。只挂载你愿意作为插件运行的模组。再次挂载模组会在同一已求值模块上重新运行 `register`,模块级变量保留其值。 - **`turn.complete` 文本** — 钩子返回的 `{ text }` 到达宿主日志,而非答案下方的一行;`durationMs` 从该轮的 `turn/start` 起算。 -- **`$.session.usage`** — 在 token 计量器得知路由的上下文窗口与提供商用量报告之前,`window` 为 `0` 且无 `percent`;`rateLimits` 始终为空。`$.fs.stat` 报告 `mtimeMs: 0`。 +- **`$.session.usage`** — 在 token 计量器得知路由的上下文窗口与提供商用量报告之前,`window` 为 `0` 且无 `percent`;未挂载 token-meter 服务时,token 数缺席且 `window` 为 `0`。`rateLimits` 始终为空。`$.fs.stat` 报告 `mtimeMs: 0`。 - **`plugin.json` 与 `hooks.json`** — 不读取;`defineMod` 承载身份,插件的设置钩子需要 `@deepseek-ai/dsh-hooks-claude-code`。 diff --git a/packages/experimental/claude-code-mods/package.json b/packages/experimental/claude-code-mods/package.json index 60fed4d115..cbf2d43624 100644 --- a/packages/experimental/claude-code-mods/package.json +++ b/packages/experimental/claude-code-mods/package.json @@ -62,7 +62,6 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-storage-domain": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 3d6363b463..84f04d6138 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -10,18 +10,14 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -// Type-only: activate the `ctx.get('subprocess')` and `ctx.get('sessionProjections')` Context declarations. +// Type-only: activate the `ctx.get('subprocess')` Context declaration. import type {} from '@deepseek-ai/dsh-subprocess' -import type {} from '@deepseek-ai/dsh-session-projection' -import type { SessionProjectionStateMap } from '@deepseek-ai/dsh-session-projection/types' import type { CommandDefinition } from '@deepseek-ai/dsh-commands' import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' -import type { Session } from '@deepseek-ai/dsh-session' import type { WorkingDirectoryService } from '@deepseek-ai/dsh-working-directory' -// Type-only: merges the token-meter projection states into SessionProjectionStateMap. import type {} from '@deepseek-ai/dsh-token-meter' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' import type { Domain } from '@deepseek-ai/dsh-storage-domain' @@ -225,11 +221,6 @@ export function createHostOps(options: HostOpsOptions): OpTable { if (agent !== undefined) options.redraw?.(agent.session.id) } - /** One projection's state, or undefined when the registry or the projection's owning plugin is not composed. */ - function projection(session: Session, key: K): SessionProjectionStateMap[K] | undefined { - return ctx.get('sessionProjections')?.stateOf(session, key) - } - return { // ---- ui: no mod drawing surface exists; log-style calls reach the host log ---- 'ui.log': (input, { mod }) => { @@ -411,14 +402,12 @@ export function createHostOps(options: HostOpsOptions): OpTable { }, 'session.turns': (_input, context) => { const agent = requireAgent(context, 'session.turns') - const boundary = projection(agent.session, 'turnBoundary') - if (boundary === undefined) throw new Error('$.session.turns needs the turnBoundary projection, which this deployment did not compose') - return boundary.lastTurn + return ctx.agents.turnBoundaryOf(agent.session).lastTurn }, 'session.messages': (_input, context) => sessionMessages(requireAgent(context, 'session.messages').session.deriveMessages(), aliases), 'session.usage': (_input, context): SessionUsage => { const agent = requireAgent(context, 'session.usage') - const pressure = projection(agent.session, 'contextPressure') + const pressure = ctx.get('tokenMeter')?.contextPressure(agent.session) const tokens = pressure?.pressureTokens const window = pressure?.contextWindow ?? 0 return { diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index 1c97870b73..b86713bff7 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -208,8 +208,8 @@ export class ClaudeCodeMods extends TypertRemoteService { bandRows: z.number().default(10), }) - // Session initialization requires its directory owner; other services are read on demand. - static inject = ['workingDirectory'] + // Session initialization requires its directory and turn-boundary reader; other services are read on demand. + static inject = ['workingDirectory', 'agents'] private readonly engine: ModsEngine private readonly registrations = new Map void>>() diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index 4ee329ce99..e5d0d19775 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -951,7 +951,7 @@ describe('loading diagnostics and configuration', () => { }) it('is a Service plugin the Loader keeps by its default export', () => { - expect(ClaudeCodeMods.inject).toEqual(['workingDirectory']) + expect(ClaudeCodeMods.inject).toEqual(['workingDirectory', 'agents']) const loader = Object.create(Loader.prototype) as Loader const unwrapped: unknown = loader.unwrapExports({ default: ClaudeCodeMods }) expect(unwrapped).toBe(ClaudeCodeMods) diff --git a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts index d4bc93702d..af4b86f1d0 100644 --- a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts +++ b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import ClaudeCodeMods, { defineMod } from '../src/index.ts' @@ -32,7 +34,11 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) - await ctx.plugin(ClaudeCodeMods, {}) + const bridge = ctx.plugin(ClaudeCodeMods, {}) + expect(ctx.get('claudeCodeMods')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await bridge const fiber = await ctx.plugin(plugin, { history: 3 }) await fiber.await() expect(seen).toEqual([{ history: 3, unit: 'tokens' }]) @@ -52,6 +58,8 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = ctx.plugin(slow, {}) await vi.waitFor(() => { expect(release).toBeDefined() }) @@ -69,6 +77,8 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = await ctx.plugin(bare, {}) await fiber.await() diff --git a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts index 15400980f4..2a86de5e61 100644 --- a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts +++ b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts @@ -4,9 +4,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import CommandRuntime from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -34,8 +35,10 @@ afterEach(async () => { const mod: LoadedMod = { name: 'unit-mod', version: undefined, root: '/mods/unit-mod', options: {}, order: 0 } -function setup(ctx = new Context()) { +async function setup(ctx = new Context()) { contexts.push(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) + if (ctx.get('agents') === undefined) await ctx.plugin(AgentRegistry) const registrations = new Map void>>() const ops = createHostOps({ ctx, @@ -72,12 +75,16 @@ function fakeAgent(ctx: Context, overrides: { cwd?: string; model?: string; requ id: 'fake', ctx, options: overrides.model === undefined ? {} : { model: overrides.model }, - session: { - id: 'fake', - header: { id: 'fake', createdAt: 1, ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd } }, + session: Object.assign(Session.create(SessionId('fake'), [], { + version: SESSION_FORMAT_VERSION, + id: SessionId('fake'), + createdAt: 1, + isSeeded: false, + ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd }, + }), { requestHeader: () => overrides.requestModel === undefined ? undefined : { config: { provider: 'p', model: overrides.requestModel } }, deriveMessages: (): Message[] => [], - }, + }), } return facts as Agent } @@ -100,7 +107,7 @@ describe('values', () => { describe('services a deployment did not compose', () => { it('names the missing service in each call', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() const agent = fakeAgent(ctx) await expect(call('ui.ask', { question: 'q' }, agent)).rejects.toThrow(/dsh-user-questions/) await expect(call('command.register', { name: 'x', description: 'd' }, agent)).rejects.toThrow(/dsh-commands/) @@ -113,7 +120,7 @@ describe('services a deployment did not compose', () => { await expect(call('store.get', { key: 'k' })).rejects.toThrow(/dsh-storage-domain/) await expect(call('fs.read', { path: 'x' })).rejects.toThrow(/dsh-fs/) await expect(call('process.run', { argv: ['ls'] })).rejects.toThrow(/dsh-subprocess/) - await expect(call('session.turns', {}, agent)).rejects.toThrow(/turnBoundary projection/) + await expect(call('session.turns', {}, agent)).resolves.toBe(0) // Without a drawn surface the ui calls that would redraw the band are plain no-ops. await expect(call('ui.invalidate', {}, agent)).resolves.toBeUndefined() await expect(call('ui.invalidate', {})).resolves.toBeUndefined() @@ -123,7 +130,7 @@ describe('services a deployment did not compose', () => { }) it('requires a session for session-bound calls and validates mod input', async () => { - const { call } = setup() + const { call } = await setup() for (const op of ['ui.ask', 'command.run', 'command.list', 'prompt.submit', 'session.id', 'session.cwd', 'session.root', 'session.model', 'session.turns', 'session.messages', 'session.usage']) { await expect(call(op, {})).rejects.toThrow(`$.${op} needs a session, and this event has none`) } @@ -137,7 +144,7 @@ describe('services a deployment did not compose', () => { describe('session facts from the agent', () => { it('falls back from the logged route to the agent option to an empty model, and from the session cwd to the process cwd', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() expect(await call('session.model', {}, fakeAgent(ctx, { requestModel: 'logged', model: 'opt' }))).toBe('logged') expect(await call('session.model', {}, fakeAgent(ctx, { model: 'opt' }))).toBe('opt') expect(await call('session.model', {}, fakeAgent(ctx))).toBe('') @@ -157,7 +164,7 @@ describe('files through the filesystem seam', () => { dirs.push(root) const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: root }) - const { call } = setup(ctx) + const { call } = await setup(ctx) provideWorkingDirectoryFixture(ctx) writeFileSync(join(root, 'small.txt'), 'small') writeFileSync(join(root, 'big.txt'), Buffer.alloc(FS_MAX_BYTES + 1, 97)) @@ -182,7 +189,7 @@ describe('processes through the subprocess seam', () => { it('runs argv with an explicit cwd and env, and reports a signal-terminated child', async () => { const ctx = new Context() await ctx.plugin(LocalSubprocessRuntime) - const { call } = setup(ctx) + const { call } = await setup(ctx) const cwd = mkdtempSync(join(tmpdir(), 'dsh-cc-mods-proc-')) dirs.push(cwd) const run = await call('process.run', { @@ -220,7 +227,7 @@ describe('the store limit', () => { close: () => Promise.resolve(), } ctx.provide('storageDomain', { open: () => Promise.resolve(domain) }) - const { call } = setup(ctx) + const { call } = await setup(ctx) expect(await call('store.keys', {})).toEqual([]) await call('store.set', { key: 'ok', value: 1 }) expect(await call('store.keys', {})).toEqual(['ok']) @@ -247,7 +254,7 @@ describe('registrations outside a session', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(CommandRuntime) - const { call, registrations, engine } = setup(ctx) + const { call, registrations, engine } = await setup(ctx) await call('command.register', { name: 'Global', description: 'global command' }) await call('tool.register', { name: 'global_tool', description: 'global tool' }) expect(ctx.tools.schemas().map(tool => tool.name)).toContain('mcp__unit-mod__global_tool') @@ -264,7 +271,7 @@ describe('registrations outside a session', () => { describe('ui.log sinks', () => { it('writes transcript lines at info and debug lines at debug', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() const info = vi.fn() const debug = vi.fn() ctx.logger.info = info as never @@ -293,7 +300,7 @@ describe('the committed directory owner', () => { await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) const harness = await mountAgentLoopTestHarness(ctx) await ctx.plugin(LocalSubprocessRuntime) - const { call } = setup(ctx) + const { call } = await setup(ctx) const agent = await harness.create(SessionId('directory-owner'), {}, { cwd: origin }) await ctx.workingDirectory.set(agent, current) @@ -322,7 +329,7 @@ describe('the committed directory owner', () => { }) it('fails explicitly when a session-bound operation has no directory owner', async () => { - const { ctx, call } = setup() + const { ctx, call } = await setup() await ctx.plugin(LocalFileSystem) await ctx.plugin(LocalSubprocessRuntime) const agent = fakeAgent(ctx) diff --git a/packages/experimental/claude-code-mods/tsconfig.json b/packages/experimental/claude-code-mods/tsconfig.json index a4aad54eac..2b285b1ded 100644 --- a/packages/experimental/claude-code-mods/tsconfig.json +++ b/packages/experimental/claude-code-mods/tsconfig.json @@ -44,9 +44,6 @@ { "path": "../../llm/token-meter" }, - { - "path": "../../session/session-projection" - }, { "path": "../../storage/storage-domain" }, diff --git a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts index 87b2c75895..510876b50c 100644 --- a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts +++ b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts @@ -6,8 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' -import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import { createSessionTestController } from '../../../api/session-controller/tests/test-remote.ts' import Translator from '@deepseek-ai/dsh-experimental-translator' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import * as JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -67,8 +66,9 @@ async function fixture(origin?: 'subagent') { saveSelection: async () => {}, } as never) ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never) - await ctx.plugin(RemoteAccessService) - const sessions = new SessionController(ctx, { nativeOpen: false }) + const sessions = await createSessionTestController(ctx, { + nativeOpen: false, cwd: root, defaultModelSelection: () => ({ provider: 'unused', model: 'unused' }), + }) await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` })) const ui = ctx.plugin(CotTranslationController, {}) await ui diff --git a/packages/experimental/cua-bundle/README.i18n.yaml b/packages/experimental/cua-bundle/README.i18n.yaml new file mode 100644 index 0000000000..f2f7091bbf --- /dev/null +++ b/packages/experimental/cua-bundle/README.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for README.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 packages/experimental/cua-bundle/README.md +/: + en: cc518dcd97629a96 + zh: 465e999011282dc3 +/deepseek-ai-dsh-cua-bundle: + en: 9084fb48d0f6489c + zh: ce692972d91cf663 +/deepseek-ai-dsh-cua-bundle/summary: + en: 06d8bf4ee1ef6f3b + zh: 8568f649fb846615 +/deepseek-ai-dsh-cua-bundle/table-of-contents: + en: b563e4ec4727d0de + zh: d0228da978238e05 +/deepseek-ai-dsh-cua-bundle/use-this-package: + en: 32932a00acbfe4b6 + zh: 34b6fa6933e4e8c9 +/deepseek-ai-dsh-cua-bundle/understand-the-implementation: + en: 057771fe13aaa337 + zh: 4fdb2b98ff3e7a78 +/deepseek-ai-dsh-cua-bundle/further-exploration: + en: 9f15d56b5b16e1ca + zh: 75eb08e8828c8877 +/deepseek-ai-dsh-cua-bundle/model-experience: + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-cua-bundle/model-experience/computer-use-in-ptc-sessions: + en: e8f11f45b375771e + zh: e8f11f45b375771e +/deepseek-ai-dsh-cua-bundle/model-experience/computer-use-in-ptc-sessions/what-the-model-sees: + en: 591af735d1eb92f6 + zh: 3632bffa33c77b28 +/deepseek-ai-dsh-cua-bundle/model-experience/computer-use-in-ptc-sessions/token-effect: + en: e8de232e96220fe7 + zh: 5c791307e2123667 +/deepseek-ai-dsh-cua-bundle/model-experience/computer-use-in-ptc-sessions/kv-cache-effect: + en: 417c40acd93df532 + zh: 69cc5cf7140c75a9 +/deepseek-ai-dsh-cua-bundle/known-limitations-and-deferred-work: + en: 41f8c54d76d92c95 + zh: 44c9662e437f4cbe +/deepseek-ai-dsh-cua-bundle/known-limitations-and-deferred-work/dev-note: + en: 0a1253c6f7b5e8b9 + zh: ce96645b3e75874e diff --git a/packages/experimental/cua-bundle/README.md b/packages/experimental/cua-bundle/README.md new file mode 100644 index 0000000000..40e17fd90b --- /dev/null +++ b/packages/experimental/cua-bundle/README.md @@ -0,0 +1,94 @@ +--- +description: "Enable optional macOS Computer Use and its permission page." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-cua-bundle + +English | [中文](README.zh.md) + +## Summary + +Enable macOS Computer Use from Plugins for existing and new PTC sessions. Windows support is in development. This bundle remains in `experimental` until that support is complete, then moves to the `computer-use` group. It is off by default; disabled profiles prepare no native components and register neither CUA tools nor their model guidance. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) + +----- + + +## Use this package + +This workflow targets developers running macOS Web or unpackaged Desktop from source. Open Plugins → Computer Use and enable the switch. The permission page identifies the responsible application reported by macOS when the optional system query is available. Request access and enable that application in System Settings; follow the application named by macOS if it differs from the detected source. For Web, run these commands from the repository root: + +```sh +pnpm run build +pnpm dsh web +``` + +For the source Desktop launcher and its separate development profile, follow [Desktop development](../../../apps/desktop/README.md). Enable Computer Use in each profile that needs it. + +The switch saves bundle selection in the profile’s `package.json`. Computer Use is installed only in PTC sessions. Enabling from the plugin list offers setup guidance when system permissions need attention; enabling from its detail page displays the permission controls directly. The detail page offers **Set to PTC** for the new-session default; existing sessions keep their preset. The switch leaves PTC execution mode and other settings unchanged. The detail page’s **Use persistent mode** action separately enables in-memory state across PTC calls; its effects and reload behavior are described in the [permission page](../cua/README.md). Native preparation runs when the provider loads and reuses the source-hashed executable when available. + +Disabling unloads the provider and withdraws CUA tools from its Agent scopes. Runtime configuration, the new-session default and saved grants remain. Session-only application decisions end with provider disposal. + +Computer Use requires a macOS DSH Host. On other operating systems, installation omits the optional macOS provider and the bundle skips its Loader entry. Shared CUA logic and the permission-page entry remain available, but Computer Use operations require the provider. A browser on another operating system can connect to a supported Mac Host. + +If preparation fails, the saved selection remains and CUA operations stay unavailable. Read the Host startup diagnostic, correct its cause, then restart DSH. + +Explicit activation runs a read-only permission check, which may start a helper without opening a system dialog. Open the [permission page](../cua/README.md) for system requests, settings navigation and automatic refresh. Tasks ask separately before accessing an application. Saved application decisions use the existing profile configuration; session decisions remain in memory across turns and connections until DSH restarts or Computer Use is disabled. Revoke access on the same page. + +----- + + +## Understand the implementation + +
    +Maintainer details + +The bundle patch mounts the native provider and permission UI with `agentPreset: ptc`. Existing Agent scopes register the tools without changing preset documents. The detail-page action uses the existing settings form to save `agent-preset-registry.selectedDefault: ptc` only when clicked. [CUA](../cua/README.md) owns registration and disposal. + +
    + +----- + + +## Further Exploration + +[Computer use](../../../docs/subsystems/computer-use.md) + +----- + + +## Model Experience + +### Computer Use in PTC sessions + +#### What the model sees + +Existing and new PTC sessions receive `cua` through the generated SDK and `run_code`, plus CUA guidance. Disabling withdraws these contributions and keeps PTC presentation. Other presets receive no CUA contributions. [CUA](../cua/README.md) and [PTC](../../ptc-runtime/ptc-runtime-node/README.md#persistent-repl) own the interfaces and output semantics. + +#### Token effect + +CUA activation adds its guidance. Disabling removes that guidance; the existing PTC execution instructions remain. + +#### KV Cache effect + +CUA activation and removal change the PTC SDK and system guidance; subsequent requests use the updated prefix. The top-level `run_code` transport remains present. + +## Known Limitations and Deferred Work + + + +- Source Web and Desktop share permission controls on macOS. Packaged Desktop helper distribution, signing, and permission attribution are deferred. +- PTC execution is shared by the profile and is configured independently. A Host without HMR applies saved changes at restart. Saving or revoking application grants while another HMR transaction unloads the provider can block; queued-write cancellation is a separate pending lifecycle fix. + + +### Dev Note + +None. diff --git a/packages/experimental/cua-bundle/README.zh.md b/packages/experimental/cua-bundle/README.zh.md new file mode 100644 index 0000000000..96d7c1076c --- /dev/null +++ b/packages/experimental/cua-bundle/README.zh.md @@ -0,0 +1,94 @@ +--- +description: "开启可选 macOS Computer Use 及权限页面。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-cua-bundle + +[English](README.md) | 中文 + +## 概述 + +从插件页为已有和新建 PTC 会话开启 macOS Computer Use。Windows 支持正在开发中。本组合包暂放在 `experimental`,待 Windows 支持完成后迁回 `computer-use` 组。默认关闭,关闭时不准备本机组件、不注册 CUA 工具或添加其模型指引。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) + +----- + + +## 使用本包 + +本流程面向从源码运行 macOS Web 或未打包 Desktop 的开发者。打开“插件 → Computer Use”并启用。权限页通过可选系统查询显示 macOS 报告的责任应用,指引用户申请权限并在系统设置中开启对应开关。若 macOS 显示其他应用名称,以系统提示为准。Web 版在仓库根目录运行以下命令: + +```sh +pnpm run build +pnpm dsh web +``` + +源码 Desktop 的启动方式和独立开发配置档见[桌面开发说明](../../../apps/desktop/README.zh.md)。需要使用 Computer Use 的配置档分别开启该功能。 + +开关将组合包选择保存到 profile 的 `package.json`。Computer Use 仅安装在 PTC 会话中。从插件列表启用时,系统权限未就绪会显示设置引导;从详情页启用后直接显示权限控件。详情页提供“设为 PTC”按钮来修改新会话默认模式,已有会话保留原 preset。开关保持 PTC 执行方式和其他设置不变。详情页的“设为持久化”按钮单独开启 PTC 跨调用的内存状态保留,作用范围和重载行为见[权限页面](../cua/README.zh.md)。提供方加载时准备本机组件,已有与源码摘要匹配的可执行文件时直接复用。 + +关闭会卸载提供方,并从其 Agent 作用域中撤回 CUA 工具。执行器配置、新会话默认值和已保存的授权保留。会话级应用授权随提供方卸载而失效。 + +Computer Use 要求 DSH Host 运行在 macOS 上。其他系统安装时跳过可选的 macOS 提供方,组合包也不加载该提供方。共享 CUA 逻辑和权限页入口仍保留,Computer Use 操作需要提供方才能使用。其他操作系统上的浏览器可以连接受支持的 Mac Host。 + +准备失败时,已保存的选择保留,CUA 操作不可用。查看 Host 的启动诊断,修复原因后重启 DSH。 + +显式启用会进行只读权限检查,可能启动 helper,但不会打开系统授权弹窗。[权限页面](../cua/README.zh.md) 提供系统申请入口、设置跳转和自动检测。任务访问应用前会单独询问用户。长期应用授权保存在现有 profile 配置中,会话授权保存在内存中,跨 turn 和连接复用,重启 DSH 或关闭 Computer Use 后失效。可以在同一页面撤销授权。 + +----- + + +## 理解实现 + +
    +维护者说明 + +组合包 patch 通过 `agentPreset: ptc` 挂载原生提供方和权限界面。工具通过现有 Agent 作用域注册,preset 文档保持不变。详情页按钮复用现有设置表单,仅在点击后保存 `agent-preset-registry.selectedDefault: ptc`。[CUA](../cua/README.zh.md)负责注册和销毁。 + +
    + +----- + + +## 进一步探索 + +[Computer use](../../../docs/subsystems/computer-use.zh.md) + +----- + + +## 模型体验 + +### Computer Use in PTC sessions + +#### What the model sees + +已有和新建 PTC 会话通过生成的 SDK 与 `run_code` 使用 `cua`,并获得 CUA 指引。关闭撤回这些贡献,保留 PTC 展示方式。其他 preset 不获得 CUA 贡献。完整接口与输出约定由 [CUA](../cua/README.zh.md) 和 [PTC](../../ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl) 说明。 + +#### Token effect + +CUA 生效时添加其指引。关闭撤回这些指引,现有 PTC 执行说明保留。 + +#### KV Cache effect + +CUA 生效和卸载会改变 PTC SDK 与系统指引,后续请求使用更新后的前缀。顶层 `run_code` 工具保持存在。 + +## 已知限制与延后工作 + + + +- 源码 Web 与 Desktop 共用 macOS 权限控件。桌面安装包的 helper 分发、签名和权限归属留待后续处理。 +- PTC 执行方式由 profile 共享,需单独配置。未启用 HMR 的 Host 在重启时应用已保存的变更。保存或撤销应用授权与另一次卸载提供方的 HMR 同时发生时可能阻塞;排队写入取消另行修复。 + + +### 开发备注 + +None. diff --git a/packages/experimental/cua-bundle/cordis.patch.yml b/packages/experimental/cua-bundle/cordis.patch.yml new file mode 100644 index 0000000000..5d5fb48b6f --- /dev/null +++ b/packages/experimental/cua-bundle/cordis.patch.yml @@ -0,0 +1,10 @@ +- insert: + - id: cua-provider-registry + name: '@deepseek-ai/dsh-computer-use' + - id: cua + name: '@deepseek-ai/dsh-experimental-cua-macos' + disabled: !!js process.platform !== 'darwin' + config: + agentPreset: ptc + - id: ui-cua + name: '@deepseek-ai/dsh-cua' diff --git a/packages/experimental/cua-bundle/icon.svg b/packages/experimental/cua-bundle/icon.svg new file mode 100644 index 0000000000..5d50e0fa5a --- /dev/null +++ b/packages/experimental/cua-bundle/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/experimental/cua-bundle/locale/en.json b/packages/experimental/cua-bundle/locale/en.json new file mode 100644 index 0000000000..eaab4261ac --- /dev/null +++ b/packages/experimental/cua-bundle/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Computer Use", + "description": "Currently supports macOS; Windows support is in development. Loads Computer Use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it." + } +} diff --git a/packages/experimental/cua-bundle/locale/zh.json b/packages/experimental/cua-bundle/locale/zh.json new file mode 100644 index 0000000000..3daf33eab3 --- /dev/null +++ b/packages/experimental/cua-bundle/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Computer Use", + "description": "目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。" + } +} diff --git a/packages/experimental/cua-bundle/package.json b/packages/experimental/cua-bundle/package.json new file mode 100644 index 0000000000..d4e4438b46 --- /dev/null +++ b/packages/experimental/cua-bundle/package.json @@ -0,0 +1,56 @@ +{ + "name": "@deepseek-ai/dsh-cua-bundle", + "description": "Optional macOS Computer Use and permission setup", + "version": "0.2.1-rc.2", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/cua-bundle" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-computer-use": "workspace:*", + "@deepseek-ai/dsh-cua": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "js-yaml": "^4.2.0", + "@deepseek-ai/cordis-plugin-loader": "workspace:~" + }, + "optionalDependencies": { + "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*" + } +} diff --git a/packages/experimental/cua-bundle/src/index.ts b/packages/experimental/cua-bundle/src/index.ts new file mode 100644 index 0000000000..f04783f7c0 --- /dev/null +++ b/packages/experimental/cua-bundle/src/index.ts @@ -0,0 +1,2 @@ +/** Optional Computer Use composition; configuration rows live in cordis.patch.yml. */ +export {} diff --git a/packages/experimental/cua-bundle/tests/platform.spec.ts b/packages/experimental/cua-bundle/tests/platform.spec.ts new file mode 100644 index 0000000000..1a5cf24aec --- /dev/null +++ b/packages/experimental/cua-bundle/tests/platform.spec.ts @@ -0,0 +1,35 @@ +/** The shipped patch leaves shared entries available while skipping an absent native provider. */ +import { readFile } from 'node:fs/promises' +import { Context } from '@deepseek-ai/cordis' +import Loader, { interpolate, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { load } from 'js-yaml' +import { expect, it, vi } from 'vitest' + +it.each(['darwin', 'win32', 'linux'])('loads the native provider only on Darwin for %s', async (platform) => { + const patch = load(await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8'), { + schema: entryListSchema, + }) as { insert: EntryOptions[] }[] + const ctx = new Context() + await ctx.plugin(Loader) + if (!ctx.loader.internal) throw new Error('Loader module importer is unavailable') + const imported = vi.spyOn(ctx.loader.internal, 'import').mockImplementation(async (name: string) => { + if (platform !== 'darwin' && name === '@deepseek-ai/dsh-experimental-cua-macos') { + throw new Error('native provider is absent from this installation') + } + return { name, apply: () => {} } + }) + try { + for (const row of patch[0]!.insert) { + await ctx.loader.create({ ...row, disabled: Boolean(interpolate({ process: { platform } }, row.disabled)) }) + } + await ctx.loader.await() + const imports = imported.mock.calls.map(call => call[0]) + expect(imports).toContain('@deepseek-ai/dsh-cua') + expect(imports).toContain('@deepseek-ai/dsh-computer-use') + expect(imports.includes('@deepseek-ai/dsh-experimental-cua-macos')).toBe(platform === 'darwin') + } finally { + await ctx.fiber.dispose() + imported.mockRestore() + } +}) diff --git a/packages/experimental/cua-bundle/tsconfig.json b/packages/experimental/cua-bundle/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/experimental/cua-bundle/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index d582d3a4c0..c2cb1bfdfa 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -12,11 +12,11 @@ en: 4549f02fc53854a5 zh: 6f74f1130b6623a8 /deepseek-ai-dsh-experimental-cua-macos/table-of-contents: - en: d152484eb41ac6b4 - zh: 7b30b973a849247d + en: 9353d17b05f2f88c + zh: 6b2c6b4ee0046294 /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: d9a238f761453123 - zh: 849fd5fdccf45b95 + en: 7126e254aaf42772 + zh: fa05ac2346e022ff /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: e7909c9b43e44fbc zh: 98949877d4ea3a86 @@ -45,8 +45,8 @@ en: a2c9acb5a4320dda zh: e8d1586af60215c4 /deepseek-ai-dsh-experimental-cua-macos/known-limitations-and-deferred-work: - en: 9d8af4c39fe407a3 - zh: afec49eedfd702cb + en: de72c8c1184cb8dd + zh: 33fd21a93e51fee1 /deepseek-ai-dsh-experimental-cua-macos/known-limitations-and-deferred-work/dev-note: en: b836f01bd0bceb4a zh: 8122fa08b2aa95fa diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 4dbd51e4c2..5a15eb8dbe 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -20,12 +20,18 @@ Inspect macOS permissions, select running applications and windows, read accessi - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) +The helper supplies bounded 64-pixel PNG application icons for permission-page application lists. Icon reads do not launch an application or inspect its windows; executable and signing checks still determine authorization. + +The shared helper queries the process macOS considers responsible for it and captures that application’s name and icon before announcing readiness. A Desktop app launched from Terminal can therefore identify Terminal even when another GUI application sits between it and the helper. Permission queries retain this metadata after the responsible application exits and across connections sharing the helper. If the system query or application metadata is unavailable, the field is omitted and the UI uses the system prompt for guidance. + ----- ## Use this package -Enable this experimental provider through explicit source composition on macOS 14 or later. Windows support is in development. Shipped profiles do not mount it. Prepare a native helper and load the registry/provider overlay: +This package is an optional dependency restricted to macOS. Prepare the matching executable under `native/bin` before packing; that directory is included in the archive. Installed packages require the prepared helper and do not compile Swift. + +For repository Web use, enable [Computer Use](../cua-bundle/README.md) in Plugins. This prepares the local executable on macOS 14 or later; Windows support is in development. Subsequent builds check it while the existing Web profile keeps the bundle selected. Disabled profiles do not prepare native components. Explicit source composition is also available: ```sh swift build --package-path packages/experimental/cua-macos/native --configuration release @@ -34,10 +40,11 @@ export DSH_CUA_HELPER="$cua_native_root/.build/release/dsh-cua-native" pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis.yml --dump-config ``` -The dump command checks profile composition without a model request. For a real task, replace `--dump-config` with a prompt and configure the profile’s model credentials. The overlay mounts the computer-use registry and this provider; remove an existing competing provider before using it. The responsible host/helper needs Accessibility permission for AX/input and Screen Recording permission for capture. `getCapabilities` reports permissions and session lock state without opening a system dialog. Locked or unavailable desktop sessions reject UI observations and input before dispatch; the user must unlock macOS before resuming. `listApps` and `cua.getState` list running applications. `findApps(query)` searches standard macOS application directories by name, bundle ID or absolute path. `getApp` selects a unique name, bundle ID, path or running PID, obtains application consent, and launches a stopped app in the background when needed. A stopped executable is identified before approval and checked again before and after launch; unsigned code only receives task access. The returned object reads initial AX without activating the app. Launch cancellation may leave the application running. Model guidance starts with background observation and input, without activation during initialization or before each action. For hidden or minimized windows, try `app.show()` or `window.show()` and read fresh state. This requests unhide and unminimize without activation, raising or switching Spaces; app-level restoration selects the focused, main or sole AX window. The result names the selected `windowId`, or null when no window was selected, and reports `foregroundChanged` by comparing the front app and top window before and after the request. Null means foreground evidence was unavailable; later focus changes remain possible. If restoration fails or the window remains unusable, explain the needed foreground step, call `app.activate()` or an observed window’s `raise` action, then observe again. Necessary foreground access takes priority over preserving focus. Applications may change focus after input; background delivery does not guarantee an undisturbed user session. +The dump command checks profile composition without a model request. For a real task, replace `--dump-config` with a prompt and configure the profile’s model credentials. The overlay mounts the computer-use registry and this provider; remove an existing competing provider before using it. The responsible host/helper needs Accessibility permission for AX/input and Screen Recording permission for capture. `getCapabilities` reports permissions and session lock state without opening a system dialog. Locked or unavailable desktop sessions reject UI observations and input before dispatch; the user must unlock macOS before resuming. `listApps` and `cua.getState` list running applications. `findApps(query)` searches standard macOS application directories by name, bundle ID or absolute path. `getApp` selects a unique name, bundle ID, path or running PID, obtains application consent, and launches a stopped app in the background when needed. A stopped executable is identified before approval and checked again before and after launch; unsigned code only receives session access for the selected instance. The returned object reads initial AX without activating the app. Launch cancellation may leave the application running. Model guidance starts with background observation and input, without activation during initialization or before each action. For hidden or minimized windows, try `app.show()` or `window.show()` and read fresh state. This requests unhide and unminimize without activation, raising or switching Spaces; app-level restoration selects the focused, main or sole AX window. The result names the selected `windowId`, or null when no window was selected, and reports `foregroundChanged` by comparing the front app and top window before and after the request. Null means foreground evidence was unavailable; later focus changes remain possible. If restoration fails or the window remains unusable, explain the needed foreground step, call `app.activate()` or an observed window’s `raise` action, then observe again. Necessary foreground access takes priority over preserving focus. Applications may change focus after input; background delivery does not guarantee an undisturbed user session. | Field | Default | Meaning | | --- | --- | --- | +| `agentPreset` | `ptc` | Register tools and guidance only for Agents bound to this preset; `false` registers globally. | | `allowedApps` | `[]` | Live profile-local application consent, keyed by executable path and verified signing requirement. | | `helperPath` | package-owned executable | Optional absolute native executable override. | | `buildTimeoutMs` | `300000` | Maximum native preparation time. | @@ -60,7 +67,7 @@ Keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain th Agent disposal and plugin unload cancel and await the complete tool call, including image admission and attachment storage. An image write already in progress must settle before disposal returns; a canceled call cannot return a successful screenshot. -Application consent is enforced before AX reads, window-title reads, screenshots, activation, and input through the CUA tool. User Questions offers task access or denial, plus saved access when the application has a verifiable signature and the composition provides settings persistence. Task access is bound to the selected process handle; saved access is bound to its canonical executable path and designated code-signing requirement. Denial prevents repeated prompts in that task. Removing a saved `allowedApps` entry cancels affected operations and invalidates their helper handles; reauthorization requires fresh selection. Other handles on the same Agent connection also expire, while saved grants remain reusable. Agent/provider disposal joins cleanup of the corresponding native connection. See the [application consent decision](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md). These checks govern CUA calls; persistent PTC and separately authorized shell tools retain their own execution authority. +Application consent is enforced before AX reads, window-title reads, screenshots, activation, and input through the CUA tool. The first selection asks the human for session access, denial, or saved access when the application has a verifiable signature. Session access is kept in memory by session and application identity, survives agent and helper replacement, and expires when DSH restarts or Computer Use is disabled. Signed application identities combine the canonical executable path and designated code-signing requirement; unverifiable applications remain bound to their process instance. Denial prevents repeated prompts in that session. Revocation reads and removes saved access in the same queue as grant creation, invalidates each affected Agent connection, and awaits its native cleanup without replaying input. Every app handle on those Agent connections becomes unusable; other applications need fresh selection, but their session and saved grants remain reusable. See the [application consent decision](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md). These checks govern CUA calls; persistent PTC and separately authorized shell tools are not an operating-system sandbox. Raw `getState` exposes validated state fields and the complete text for the requested disclosure options. The native helper also returns structured element diagnostics for native checks; the tool projection omits that duplicate array from model text and structured tool values. @@ -148,7 +155,7 @@ The plugin contributes one `cua` operation tool with method-specific parameters. ##### Operation guidance (verbatim) ```markdown -Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly grants access again. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. +Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly permits another authorization request. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. Initialize the object API once in persistent PTC. Reuse its variables in later calls; put temporary declarations inside { ... } blocks to avoid redeclaration errors. All object methods are async. getAXState returns text, not a structured state object. Its text separates values and placeholders, and reports subroles, help, URLs, disabled/focused/selected/expanded state and UTF-16 selection ranges when available. Writability is true, false or unknown on relevant controls; omitted state fields do not assert false. selection=unavailable means the reported range exceeds the observed text. Compact output omits ordinary Press/Pick (click), ShowMenu (right click) and ScrollToVisible actions; details=true lists every available action. Use the displayed action name with performSecondaryAction; ambiguous aliases require the complete name from details=true. Truncation reports the reached provider limit. Narrow the window or increase that provider limit before reading again. @@ -185,7 +192,7 @@ App and Window methods: Raw operation tools use { method, params }, including params: {} for parameterless methods. getApp selects without activating the app. Keep applications in the background for observation and routine input. Do not activate or raise an app during initialization, before each action, or merely because another app is foreground. Prefer advertised AX actions. If a window is hidden or minimized, try app.show() or window.show() and read fresh state. show requests unhide and unminimize without activation, raising or switching Spaces; it chooses the explicit, focused, main or sole AX window. A null windowId means no window was selected for restoration. foregroundChanged compares the front app and top window before and after the request; null means unavailable, and delayed focus changes remain possible. If restoration is unsupported or the window remains unusable, including another Space or Stage Manager, explain the needed foreground step, use app.activate() or an advertised raise action, and read fresh state. Completing the task takes priority when foreground access is necessary. Avoid repeated activation when the user switches away; continue background-capable steps and report what requires foreground access. Applications may still change focus in response to input. -Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant macOS Accessibility or Screen Recording access in System Settings. After granting access, observe fresh state before continuing; do not automatically replay prior input. +Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant the required macOS system permissions for macOS Accessibility or Screen Recording. After granting access, observe fresh state before continuing; do not automatically replay prior input. Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. @@ -211,7 +218,7 @@ The queue orders individual native requests. Holding an app object does not rese - macOS Stage Manager can reduce background window bounds and screenshots to thumbnails. Full-window observation and coordinate input in this state are not guaranteed; model guidance requires explaining foreground needs and avoiding repeated focus reclamation. - Background input depends on private SkyLight symbols; macOS updates can make them unavailable. Validate each target OS version. - Observed failures include dispatched Unicode text not appearing in Blender and background Command+A not selecting all text in AppKit. Inspect application state after input; a successful tool result confirms dispatch only. -- Native binaries require a local build. Signing, distribution, and system-permission onboarding remain deferred. +- Native binaries require a local build. Signing and Desktop distribution remain future work. The [permission page](../cua/README.md) offers macOS prompts and app revocation; packaged Desktop permission attribution remains deferred. - Persistent PTC is an external dependency assumption, not an implementation in this package. Real-model task success requires separate evaluation beyond native and protocol fixtures. ----- diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index a8efddbbf7..223079a130 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -20,12 +20,18 @@ kind: "package-reference" - [已知限制与后续工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) +helper 为权限页的应用列表提供 64 像素 PNG 图标。读取图标不会启动应用或检查窗口,授权仍由可执行文件和签名校验决定。 + +共享 helper 查询 macOS 为它记录的责任进程,在发布就绪状态前保存对应应用的名称和图标。因此,从 Terminal 启动 Desktop 时,即使中间还有另一个 GUI 应用,也能识别到 Terminal。责任应用退出后,权限查询仍保留这些信息,后续复用该 helper 的连接读取同一份记录。系统查询或应用信息不可用时省略该字段,由界面引导用户按系统弹窗选择授权对象。 + ----- ## 使用本包 -该实验性提供方需要在 macOS 14 及以上通过源码配置显式启用。Windows 支持正在开发中。默认配置不加载它。先准备原生组件,再加载注册表和提供方覆盖层: +本包是仅限 macOS 的可选依赖。打包前需在 `native/bin` 下准备对应架构的可执行文件,该目录会包含在发布包中。安装版需要准备好的 helper,不会编译 Swift。 + +从仓库运行 Web 时,在插件页开启 [Computer Use](../cua-bundle/README.zh.md)。首次启用会在 macOS 14 及以上准备本机组件,Windows 支持正在开发中。现有 Web profile 保持选中时,后续构建继续检查更新。未开启的 profile 不准备原生组件。也可以显式加载源码覆盖层: ```sh swift build --package-path packages/experimental/cua-macos/native --configuration release @@ -34,10 +40,11 @@ export DSH_CUA_HELPER="$cua_native_root/.build/release/dsh-cua-native" pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis.yml --dump-config ``` -配置输出命令不发起模型请求。执行真实任务时,将 `--dump-config` 替换为任务提示,并配置该 profile 的模型凭据。配置挂载 computer-use 注册表和本 provider;使用前需移除已有的竞争 provider。负责执行的宿主或辅助进程需要辅助功能权限来读取 AX 和输入,需要屏幕录制权限来截图。`getCapabilities` 查询权限和锁屏状态,不弹出系统授权窗口。桌面会话锁定或不可用时,界面观察与输入会在分派前拒绝,需由用户解锁 macOS 后继续。`listApps` 和 `cua.getState` 列出运行中的应用。`findApps(query)` 按名称、bundle ID 或绝对路径搜索 macOS 标准应用目录。`getApp` 按唯一名称、bundle ID、路径或运行中的 PID 选择应用,取得应用许可后,在需要时后台启动尚未运行的应用。启动前先确认可执行文件身份,授权后在启动前后再次核对;未签名代码只取得任务许可。返回的对象读取初始 AX,不自动激活应用。启动被取消时,应用可能已经运行。模型指引要求先在后台观察和输入,不在初始化或每次动作前激活应用。窗口隐藏或最小化时,先尝试 `app.show()` 或 `window.show()`,再重新观察。它请求取消隐藏和恢复最小化,不主动激活、抬起窗口或切换桌面空间;应用级恢复选择焦点窗口、主窗口或唯一 AX 窗口。结果通过 `windowId` 指明选中的窗口,未选中时为 null;`foregroundChanged` 比较操作前后的前台应用和最上层窗口,无法取得完整观察时为 null,返回后仍可能发生焦点变化。恢复失败或窗口仍不可用时,说明需要前台的步骤,再调用 `app.activate()` 或窗口的 `raise` 动作并重新观察。必须使用前台时,以完成任务为先。应用收到输入后仍可能改变焦点,后台投递不保证用户操作始终不受干扰。 +配置输出命令不发起模型请求。执行真实任务时,将 `--dump-config` 替换为任务提示,并配置该 profile 的模型凭据。配置挂载 computer-use 注册表和本 provider;使用前需移除已有的竞争 provider。负责执行的宿主或辅助进程需要辅助功能权限来读取 AX 和输入,需要屏幕录制权限来截图。`getCapabilities` 查询权限和锁屏状态,不弹出系统授权窗口。桌面会话锁定或不可用时,界面观察与输入会在分派前拒绝,需由用户解锁 macOS 后继续。`listApps` 和 `cua.getState` 列出运行中的应用。`findApps(query)` 按名称、bundle ID 或绝对路径搜索 macOS 标准应用目录。`getApp` 按唯一名称、bundle ID、路径或运行中的 PID 选择应用,取得应用许可后,在需要时后台启动尚未运行的应用。启动前先确认可执行文件身份,授权后在启动前后再次核对;未签名代码只能取得所选实例的会话许可。返回的对象读取初始 AX,不自动激活应用。启动被取消时,应用可能已经运行。模型指引要求先在后台观察和输入,不在初始化或每次动作前激活应用。窗口隐藏或最小化时,先尝试 `app.show()` 或 `window.show()`,再重新观察。它请求取消隐藏和恢复最小化,不主动激活、抬起窗口或切换桌面空间;应用级恢复选择焦点窗口、主窗口或唯一 AX 窗口。结果通过 `windowId` 指明选中的窗口,未选中时为 null;`foregroundChanged` 比较操作前后的前台应用和最上层窗口,无法取得完整观察时为 null,返回后仍可能发生焦点变化。恢复失败或窗口仍不可用时,说明需要前台的步骤,再调用 `app.activate()` 或窗口的 `raise` 动作并重新观察。必须使用前台时,以完成任务为先。应用收到输入后仍可能改变焦点,后台投递不保证用户操作始终不受干扰。 | 字段 | 默认值 | 含义 | | --- | --- | --- | +| `agentPreset` | `ptc` | 仅为绑定该 preset 的 Agent 注册工具和指引;`false` 表示全局注册。 | | `allowedApps` | `[]` | 当前配置档的动态应用授权,绑定可执行文件路径和已验证的代码签名要求。 | | `helperPath` | 包内可执行文件 | 可选的原生可执行文件绝对路径覆盖。 | | `buildTimeoutMs` | `300000` | 原生组件准备的最长时间。 | @@ -60,7 +67,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。 -CUA 工具在读取 AX、窗口标题、截图、激活和输入之前检查应用授权。User Questions 提供任务级允许和拒绝选项;应用签名可验证且组合支持配置持久化时,也可保存授权。任务授权绑定所选进程句柄,长期授权绑定真实可执行路径和指定代码签名要求。拒绝后,同一任务不再反复询问。删除保存的 `allowedApps` 条目会取消相关操作并使原生组件中的句柄失效;重新授权需要重新选择应用。同一 Agent 连接中的其他句柄也会失效,已保存的授权仍可复用。Agent 或提供方销毁时等待对应连接的原生状态清理。设计理由见[应用授权决策](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md)。这些检查约束 CUA 调用;持久 PTC 和另行授权的 shell 工具保留各自的执行权限。 +CUA 工具在读取 AX、窗口标题、截图、激活和输入前检查应用授权。首次选择应用时询问用户,可选择本会话允许、拒绝;签名可验证的应用还可保存长期授权。会话许可按会话和应用身份保存在内存中,Agent 或 helper 重建后仍可复用,重启 DSH 或关闭 Computer Use 后失效。已签名应用的身份由规范化可执行文件路径和代码签名指定要求组成;签名不可验证的应用仍绑定进程实例。拒绝后,同一会话不会反复询问。撤销与长期许可的保存共用队列,在队列内读取并移除许可,然后使相关 Agent 连接失效并等待原生清理完成,不自动重放输入。同一 Agent 连接中的其他应用也需要重新选择,其他应用的会话许可和长期许可仍可复用。设计理由见[应用授权决策](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md)。这些检查的范围是 CUA 调用。persistent PTC 与已授权的 shell 仍按各自执行权限运行。 原始 `getState` 操作返回经过校验的状态字段,以及按本次披露选项生成的完整文本。原生 helper 还返回供原生检查使用的结构化元素诊断数组;工具投影会从模型文本和结构化工具值中移除这份重复数组。 @@ -148,7 +155,7 @@ AppKit sheet 可以通过其窗口 ID 单独选择,也会出现在所属文档 ##### 操作指导文本(原文) ```markdown -Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly grants access again. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. +Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly permits another authorization request. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. Initialize the object API once in persistent PTC. Reuse its variables in later calls; put temporary declarations inside { ... } blocks to avoid redeclaration errors. All object methods are async. getAXState returns text, not a structured state object. Its text separates values and placeholders, and reports subroles, help, URLs, disabled/focused/selected/expanded state and UTF-16 selection ranges when available. Writability is true, false or unknown on relevant controls; omitted state fields do not assert false. selection=unavailable means the reported range exceeds the observed text. Compact output omits ordinary Press/Pick (click), ShowMenu (right click) and ScrollToVisible actions; details=true lists every available action. Use the displayed action name with performSecondaryAction; ambiguous aliases require the complete name from details=true. Truncation reports the reached provider limit. Narrow the window or increase that provider limit before reading again. @@ -185,7 +192,7 @@ App and Window methods: Raw operation tools use { method, params }, including params: {} for parameterless methods. getApp selects without activating the app. Keep applications in the background for observation and routine input. Do not activate or raise an app during initialization, before each action, or merely because another app is foreground. Prefer advertised AX actions. If a window is hidden or minimized, try app.show() or window.show() and read fresh state. show requests unhide and unminimize without activation, raising or switching Spaces; it chooses the explicit, focused, main or sole AX window. A null windowId means no window was selected for restoration. foregroundChanged compares the front app and top window before and after the request; null means unavailable, and delayed focus changes remain possible. If restoration is unsupported or the window remains unusable, including another Space or Stage Manager, explain the needed foreground step, use app.activate() or an advertised raise action, and read fresh state. Completing the task takes priority when foreground access is necessary. Avoid repeated activation when the user switches away; continue background-capable steps and report what requires foreground access. Applications may still change focus in response to input. -Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant macOS Accessibility or Screen Recording access in System Settings. After granting access, observe fresh state before continuing; do not automatically replay prior input. +Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant the required macOS system permissions for macOS Accessibility or Screen Recording. After granting access, observe fresh state before continuing; do not automatically replay prior input. Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. @@ -211,7 +218,7 @@ Use selectText only on controls advertising selectionWritable. It selects a uniq - macOS 台前调度可能将后台窗口的尺寸与截图变为缩略图,当前无法保证这种状态下的完整画面与坐标输入;模型指引要求说明前台需求并避免反复抢回焦点。 - 后台输入依赖私有 SkyLight 符号,macOS 升级可能使其不可用,需在目标系统版本验证。 - 实测 Blender Unicode 输入曾出现分派成功但界面未显示文本,后台 AppKit 的 Command+A 曾未选中全文。输入后必须检查应用状态;工具返回成功只表示已分派。 -- 原生二进制需要本地构建。签名、分发和系统权限引导留待后续。 +- 原生二进制需要本地构建,签名和桌面分发留待后续。[权限页面](../cua/README.zh.md)提供 macOS 申请入口和应用授权撤销;桌面安装包的权限归属留待后续处理。 - 持久 PTC 是对外部运行环境的假设,本包不实现它。真实模型任务成功率需要在原生测试和协议测试之外单独评估。 ----- diff --git a/packages/experimental/cua-macos/examples/macos.cordis.yml b/packages/experimental/cua-macos/examples/macos.cordis.yml index 94942836e0..f1b471523d 100644 --- a/packages/experimental/cua-macos/examples/macos.cordis.yml +++ b/packages/experimental/cua-macos/examples/macos.cordis.yml @@ -4,4 +4,5 @@ - id: cua name: '../src/index.ts' config: + agentPreset: false helperPath: !!js process.env.DSH_CUA_HELPER diff --git a/packages/experimental/cua-macos/native/.npmignore b/packages/experimental/cua-macos/native/.npmignore new file mode 100644 index 0000000000..3168a3629c --- /dev/null +++ b/packages/experimental/cua-macos/native/.npmignore @@ -0,0 +1,3 @@ +target/ +.build/ +.swiftpm/ diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 64885c0af1..b25c409fb1 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -15,17 +15,17 @@ en: e8cbc43d38786c40 zh: 52b3ba85d98011b9 /dsh-cua-native-helper/build-and-service: - en: 824e4a2b9036d9bf - zh: 37c34a454abc6965 + en: 02b25ac1126b6094 + zh: 3995a1f46354531b /dsh-cua-native-helper/request-protocol: - en: 3202c567a6e1a654 - zh: 143eebd0096a0487 + en: c8ad13843989b314 + zh: 2a42acb2faee8803 /dsh-cua-native-helper/observation-and-input: - en: c56adf89d249f4f9 - zh: 2a36a99e0a2badef + en: c527eedc3a0ee968 + zh: ce8cc12d5a62e8ae /dsh-cua-native-helper/verification: - en: 0e42ed266cc287d8 - zh: bc5e6ca20e82dae3 + en: a2ac6f2d581a1e8b + zh: f82c7d0c13539421 /dsh-cua-native-helper/further-exploration: en: 265e1927952ce6d7 zh: 7c35d9fd98b6e6c8 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index b25b1c6e28..b44d6a65f4 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -27,7 +27,7 @@ Build with `swift build --package-path packages/experimental/cua-macos/native -- The shared service uses an owner-only Unix socket under `/tmp/dsh-cua-`, keyed by the executable SHA-256. A version lock elects one process; an additional real-executable-path lock excludes a different build at the same path. The first frame is protocol-1 `hello` with the runtime key and this connection’s byte, AX, operation, screenshot, event-spacing and cleanup limits. `ready` reports the verified runtime key, PID and instance ID. Every connection owns an independent AXWorker. The stdin/stdout entry is a diagnostic proxy to this service. -AppKit runs on the main event loop. Blocking AX and input use each connection’s serial actor; all connections share a FIFO for complete desktop operations. Socket writes do not hold that operation slot. A request cancellation waits for native work to stop; it does not cancel the connection’s lifetime latch. Screenshot timeout ends only the request. An unfinished system capture retains the single capture slot until its real callback arrives, rejects new captures with CAPTURE_BUSY and leaves AX/input available. Only an operation or cleanup that cannot stop can invalidate the shared process. The proxy accepts `--max-request-bytes`, `--max-response-bytes`, `--ax-timeout-ms`, `--timeout-ms`, `--screenshot-timeout-ms`, `--shutdown-grace-ms` and `--text-event-interval-ms`; the Host sets the same limits in hello. An AX deadline bounds an individual system call. Cancellation can follow partial input, and uncertain input is never replayed automatically. +AppKit runs on the main event loop. Blocking socket accepts and handshakes use background queues; server callbacks explicitly carry `@MainActor @Sendable` isolation and weak ownership. Shutdown releases accepted connections even when their handshake is incomplete. Blocking AX and input use each connection’s serial actor; all connections share a FIFO for complete desktop operations. Socket writes do not hold that operation slot. A request cancellation waits for native work to stop; it does not cancel the connection’s lifetime latch. Screenshot timeout ends only the request. An unfinished system capture retains the single capture slot until its real callback arrives, rejects new captures with CAPTURE_BUSY and leaves AX/input available. Only an operation or cleanup that cannot stop can invalidate the shared process. The proxy accepts `--max-request-bytes`, `--max-response-bytes`, `--ax-timeout-ms`, `--timeout-ms`, `--screenshot-timeout-ms`, `--shutdown-grace-ms` and `--text-event-interval-ms`; the Host sets the same limits in hello. An AX deadline bounds an individual system call. Cancellation can follow partial input, and uncertain input is never replayed automatically. ----- @@ -57,7 +57,7 @@ Every app-targeted operation requires `appHandle` from `getApp` in the same help Stable error codes are `INVALID_REQUEST` (malformed envelope), `INVALID_ARGUMENT` (invalid parameters), `UNSUPPORTED` (unknown operation), `APP_UNAVAILABLE`, `AMBIGUOUS_APP`, `STALE_APP`, `STALE_SNAPSHOT`, `WINDOW_UNAVAILABLE`, `WINDOW_CHANGED`, `PERMISSION_DENIED`, `FOREGROUND_REQUIRED` (hover target is not the unobscured foreground window), `PANEL_NOT_READY` (system panel recipient unavailable before input), and `NATIVE_ERROR` (remaining operating-system failures). An error never promises that a partially dispatched input was rolled back. The host must not retry input automatically. -Application selection verifies the executable path and code-signing requirement before returning an authorization identity to the Host. Unsigned running applications receive a process-lifetime identity; stopped unsigned applications use an executable digest and cannot receive persistent consent. The Host obtains human consent before launch, observation or input. +Application selection returns an authorization record to the Host: a digest of canonical executable path and verified designated signing requirement, display name, executable path, and persistable flag. Unsigned or ad-hoc-signed applications cannot receive saved consent; running instances use process-lifetime identity, while stopped applications use an executable digest. The Host strips this record from model results and obtains human consent before launch, observation or input. This pipe protocol is trusted Host transport, not a sandbox against a separately authorized shell process. Protocol declarations are generated from [the shared protocol](../../cua/src/protocol.ts). Run `pnpm exec tsx packages/experimental/cua-macos/native/generate-protocol.ts` from the repository root; package tests verify generated JSON and Swift field/enum tables. Model-submittable parameters exclude Host resource bounds and the private launch continuation. @@ -79,6 +79,8 @@ Capture and pointer delivery read CG bounds before and after an exact-window AX The responsible host/helper needs macOS Accessibility permission for AX state and input, and Screen Recording permission for screenshots. Capability preflight and permission errors do not grant permissions. Capture refuses a window that moves or resizes while the image is being read. Unassociated system panels and accessory-only applications are not selectable. +Host-only permission controls are `permissionStatus {}`, `requestPermission {permission}`, and `openPermissionSettings {permission}`, where permission is `accessibility` or `screenRecording`. They are excluded from model tools and from `getCapabilities.methods`. Permission status includes observed helper/parent process paths; these paths do not identify the TCC responsible application. The operating-system prompt names the application and asks for the user’s decision. The helper identifies the responsible process with the optional libSystem SPI `responsibility_get_pid_responsible_for_pid`, then captures its application name and icon before publishing readiness. The function is resolved dynamically, following the same signature used by [Chromium](https://chromium.googlesource.com/chromium/src/+/refs/tags/134.0.6998.28/base/process/process_info_mac.mm). If the symbol, process, or application metadata is unavailable, attribution stays unknown; it is not inferred from the nearest GUI ancestor. Permission checks remain independent of this metadata. + Observation reads each non-layout node’s descriptive properties and children through `AXUIElementCopyMultipleAttributeValues`. Per-attribute errors retain the individual-read rules; unsupported batch APIs use individual reads. Values are retained only during that observation, and input reads live attributes. Application state can change between nodes; a batch does not make the whole tree atomic. System file-panel keyboard routing resolves the remote recipient through the selected app’s focused AX sheet. Only Apple’s Open and Save Panel Service is accepted, and its focused ancestry must still contain the identified remote window. Element pointer fallbacks resolve an app-owned window or sheet through AX parents when AXWindow is unavailable. Ordinary clicks retain AX action priority over pointer input; visual click markers need no system-cursor movement. @@ -103,6 +105,8 @@ Use `python3 packages/experimental/cua-macos/native/tests/test_swift.py` for off Run `python3 packages/experimental/cua-macos/native/tests/shared_helper_e2e.py --helper /absolute/helper` to verify concurrent startup, a shared service PID, independent handles, same-application handoff, request-local screenshot deadlines, connection cleanup and signal shutdown with an owned AppKit fixture. Existing Accessibility and Screen Recording grants are required. `--without-desktop` limits the check to protocol and process lifetime. +Run `python3 packages/experimental/cua-macos/native/tests/launcher_identity_e2e.py --helper /absolute/helper` in a GUI login session to verify a Launch Services application that starts a nested GUI launcher and then the helper. The helper identifies the outer responsible application and retains its name and icon after the launchers exit. This check owns both applications and the helper, cleans up its Launch Services registrations, and does not request Accessibility or Screen Recording access. + Run `python3 packages/experimental/cua-macos/native/tests/axless_e2e.py` after building and unlocking the Mac to verify an owned window whose application removes and later restores its AX roots. The check requires existing Accessibility/Screen Recording grants, rejects stale element indices, captures the AXless window, verifies a coordinate click in the fixture state file, and checks restoration. It never changes system permission settings. Run `python3 packages/experimental/cua-macos/native/tests/observation_benchmark.py --reference-helper /absolute/reference --candidate-helper /absolute/candidate --artifacts /private/evidence` to compare complete observations of the same owned AppKit window with 35 and 400 synthetic rows. Each helper receives one warmup and three alternating measured requests. All result fields must match except helper-generated app and snapshot IDs. The report keeps raw timings, byte counts, hashes and replies; it sets no CI speed threshold and makes no memory claim. Fixture startup can activate the application. Run without other desktop automation or compilation jobs. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index eca97e0f6a..ecefb9c879 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 共享服务在 `/tmp/dsh-cua-` 下使用仅所有者可访问的 Unix socket,以可执行文件的 SHA-256 区分实例。版本锁选出一个进程,另一个真实可执行路径锁排除同一路径的不同构建。首帧为协议版本 1 的 `hello`,包含运行时标识以及该连接的字节、AX、操作、截图、事件间隔和清理限制。`ready` 返回已核实的运行时标识、PID 和实例 ID。每条连接拥有独立的 AXWorker。stdin/stdout 入口是该服务的诊断代理。 -AppKit 在主事件循环上运行。阻塞式 AX 和输入使用每条连接的串行 actor,所有连接共享用于完整桌面操作的 FIFO。socket 写出不占用操作槽位。请求取消会等待原生工作停止,不取消连接的生命期控制器。截图超时只结束该请求。未完成的系统捕获保留唯一捕获槽位,直至真实回调到达;期间新截图返回 CAPTURE_BUSY,AX 和输入仍可用。只有无法停止的操作或清理才可能使共享进程失效。代理接受 `--max-request-bytes`、`--max-response-bytes`、`--ax-timeout-ms`、`--timeout-ms`、`--screenshot-timeout-ms`、`--shutdown-grace-ms` 和 `--text-event-interval-ms`;Host 在 hello 中设置相同限制。AX 超时约束单次系统调用。取消可能发生在部分输入之后,结果不确定的输入不自动重放。 +AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后台队列,服务回调明确声明 `@MainActor @Sendable` 隔离,并弱引用服务对象。关闭时会释放已接收的连接,包括尚未完成握手的连接。阻塞式 AX 和输入使用每条连接的串行 actor,所有连接共享用于完整桌面操作的 FIFO。socket 写出不占用操作槽位。请求取消会等待原生工作停止,不取消连接的生命期控制器。截图超时只结束该请求。未完成的系统捕获保留唯一捕获槽位,直至真实回调到达;期间新截图返回 CAPTURE_BUSY,AX 和输入仍可用。只有无法停止的操作或清理才可能使共享进程失效。代理接受 `--max-request-bytes`、`--max-response-bytes`、`--ax-timeout-ms`、`--timeout-ms`、`--screenshot-timeout-ms`、`--shutdown-grace-ms` 和 `--text-event-interval-ms`;Host 在 hello 中设置相同限制。AX 超时约束单次系统调用。取消可能发生在部分输入之后,结果不确定的输入不自动重放。 ----- @@ -57,7 +57,7 @@ AppKit 在主事件循环上运行。阻塞式 AX 和输入使用每条连接的 稳定错误码包括 `INVALID_REQUEST`(信封无效)、`INVALID_ARGUMENT`(参数无效)、`UNSUPPORTED`(操作未知)、`APP_UNAVAILABLE`、`AMBIGUOUS_APP`、`STALE_APP`、`STALE_SNAPSHOT`、`WINDOW_UNAVAILABLE`、`WINDOW_CHANGED`、`PERMISSION_DENIED`、`FOREGROUND_REQUIRED`(悬停目标不是未被遮挡的前台窗口)、`PANEL_NOT_READY`(输入前系统面板接收方尚不可用)及 `NATIVE_ERROR`(其余操作系统失败)。错误不保证已分派的部分输入被回滚,Host 不得自动重试输入。 -应用选择先核实可执行路径和代码签名要求,再向 Host 返回授权身份。未签名的运行中应用使用进程生命期身份,未启动的未签名应用使用可执行文件摘要,不能获得持久授权。Host 在启动、观察和输入前取得用户许可。 +应用选择向 Host 返回授权记录:规范化可执行路径与经验证的代码签名指定要求的摘要、显示名称、可执行路径和可持久化标记。未签名或临时签名的应用不能获得长期授权;运行中实例使用进程生命期身份,未启动应用使用可执行文件摘要。Host 从模型结果中移除此记录,并在启动、观察和输入前取得用户许可。此管道协议用于可信 Host 传输,无法隔离另一个已获授权的 shell 进程。 协议声明从[共享协议](../../cua/src/protocol.ts)生成。在仓库根目录运行 `pnpm exec tsx packages/experimental/cua-macos/native/generate-protocol.ts`,包测试检查生成的 JSON 和 Swift 字段/枚举表。模型参数不包含 Host 资源上限和私有启动续接操作。 @@ -79,6 +79,8 @@ AppKit 在主事件循环上运行。阻塞式 AX 和输入使用每条连接的 负责执行的 host/helper 需要 macOS 辅助功能权限以读取 AX 和输入,需要屏幕录制权限以截图。能力预检和权限错误不授予权限。截图会拒绝在读取图片期间移动或缩放的窗口。无法确认归属的系统面板及仅后台辅助类型的应用不可选择。 +仅供 Host 使用的权限操作为 `permissionStatus {}`、`requestPermission {permission}` 和 `openPermissionSettings {permission}`,其中 permission 为 `accessibility` 或 `screenRecording`。这些操作不出现在模型工具及 `getCapabilities.methods` 中。权限状态包含观察到的 helper 和父进程路径,这些路径不能确定 TCC 归属的应用。系统授权提示负责指出应用并询问用户决定。helper 使用可选的 libSystem 内部接口 `responsibility_get_pid_responsible_for_pid` 查询责任进程,在报告就绪前记录对应应用的名称和图标。接口通过动态加载解析,函数签名与 [Chromium](https://chromium.googlesource.com/chromium/src/+/refs/tags/134.0.6998.28/base/process/process_info_mac.mm) 使用的一致。符号、进程或应用信息不可用时,归属保持未知,不根据最近的 GUI 祖先推断。权限检查独立于这些信息。 + 观察通过 `AXUIElementCopyMultipleAttributeValues` 读取每个非布局节点的描述属性和子节点。逐属性错误保留单独读取的规则;不支持批量 API 时使用单独读取。值只在本次观察期间保留,输入读取即时属性。应用状态可能在节点之间变化,批量读取不使整棵树具备原子性。 系统文件面板的键盘路由通过所选应用的 AX 焦点 sheet 解析远端接收方。只接受 Apple 的 Open and Save Panel Service,且其焦点祖先链仍须包含已识别的远端窗口。AXWindow 不可用时,元素鼠标后备路径沿 AX 父节点解析应用所属窗口或 sheet。普通点击保持 AX 动作优先,可视点击标记不需要系统鼠标移动。 @@ -103,6 +105,8 @@ AppKit 在主事件循环上运行。阻塞式 AX 和输入使用每条连接的 运行 `python3 packages/experimental/cua-macos/native/tests/shared_helper_e2e.py --helper /absolute/helper`,通过测试自建的 AppKit fixture 核实并发启动、共享服务 PID、独立句柄、同一应用交接、请求级截图期限、连接清理和信号退出。需要已有辅助功能和屏幕录制权限。`--without-desktop` 将检查限定为协议与进程生命期。 +在 GUI 登录会话中运行 `python3 packages/experimental/cua-macos/native/tests/launcher_identity_e2e.py --helper /absolute/helper`,验证由 Launch Services 启动的应用先启动另一个 GUI 启动器,再启动 helper。helper 识别外层责任应用,并在启动器退出后保留其名称和图标。检查只使用测试自建的两个应用和 helper,结束后清理对应的 Launch Services 注册,不申请辅助功能或屏幕录制权限。 + 构建并解锁 Mac 后运行 `python3 packages/experimental/cua-macos/native/tests/axless_e2e.py`,验证应用移除并恢复 AX 根节点的窗口。检查需要已有辅助功能/屏幕录制权限,会拒绝过期元素索引,捕获无 AX 窗口,通过 fixture 状态文件核实坐标点击,并检查恢复。不会修改系统权限设置。 运行 `python3 packages/experimental/cua-macos/native/tests/observation_benchmark.py --reference-helper /absolute/reference --candidate-helper /absolute/candidate --artifacts /private/evidence`,对比同一自建 AppKit 窗口中 35 行和 400 行合成内容的完整观察。每个 helper 先预热一次,再交替测量三次。除 helper 生成的应用和快照 ID 外,结果字段必须一致。报告保留原始耗时、字节数、哈希和回复,不设置 CI 性能阈值,也不声称内存表现。fixture 启动可能激活应用。运行时避免其他桌面自动化或编译任务。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/NativePlatform.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/NativePlatform.swift index 8dd6b4f61f..e4dcfe354e 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/NativePlatform.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/NativePlatform.swift @@ -36,6 +36,22 @@ struct ApplicationCatalogSnapshot: Sendable { } return ApplicationCatalogSnapshot(applications: apps, frontPID: NSWorkspace.shared.frontmostApplication?.processIdentifier) } + /// A bounded PNG for human recognition; authorization continues to use executable/signing identity. + static func icon(at path: String) -> String? { + guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 64, pixelsHigh: 64, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: bitmap) else { return nil } + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSGraphicsContext.current = context + var url = URL(fileURLWithPath: path) + while url.path != "/" && url.pathExtension != "app" { url.deleteLastPathComponent() } + let iconPath = url.pathExtension == "app" ? url.path : path + NSWorkspace.shared.icon(forFile: iconPath).draw(in: NSRect(x: 0, y: 0, width: 64, height: 64), + from: .zero, operation: .copy, fraction: 1) + return bitmap.representation(using: .png, properties: [:]).map { "data:image/png;base64," + $0.base64EncodedString() } + } static func activate(_ identity: ProcessIdentity) throws { try NativePlatform.verify(identity) guard let app = NSRunningApplication(processIdentifier: identity.pid), !app.isTerminated else { @@ -47,9 +63,18 @@ struct ApplicationCatalogSnapshot: Sendable { } enum NativePlatform { + /// Optional libSystem SPI: unavailable attribution stays unknown rather than guessing from parentage. + static func responsiblePID(_ pid: Int32) -> Int32? { + typealias Resolve = @convention(c) (pid_t) -> pid_t + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "responsibility_get_pid_responsible_for_pid") else { return nil } + let resolve = unsafeBitCast(symbol, to: Resolve.self) + let responsible = resolve(pid) + return responsible > 0 ? responsible : nil + } static func executablePath(_ pid: Int32) -> String? { var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) - return proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 ? String(cString: buffer) : nil + guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil } + return String(decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) } static func identity(_ pid: Int32) throws -> ProcessIdentity { var info = proc_bsdinfo() diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift index 72b62dda25..fd8ce9cf0b 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift @@ -3,6 +3,7 @@ enum NativeProtocol { static let modelMethods: [String] = ["activate", "click", "drag", "findApps", "getApp", "getCapabilities", "getState", "hover", "listApps", "listWindows", "paste", "performSecondaryAction", "pressKey", "screenshot", "scroll", "selectText", "setValue", "show", "typeText"] static let fields: [String: Set] = [ "activate": ["appHandle"], + "applicationIcons": ["paths"], "click": ["appHandle", "clickCount", "elementIndex", "holdMs", "modifiers", "mouseButton", "snapshotId", "windowId", "x", "y"], "drag": ["appHandle", "durationMs", "fromX", "fromY", "modifiers", "mouseButton", "snapshotId", "steps", "toX", "toY", "windowId"], "findApps": ["maxAppMatches", "query"], @@ -13,9 +14,12 @@ enum NativeProtocol { "launchApp": ["appHandle"], "listApps": [], "listWindows": ["appHandle"], + "openPermissionSettings": ["permission"], "paste": ["appHandle", "format", "maxClipboardBytes", "maxDepth", "maxElements", "pasteReadTimeoutMs", "text"], "performSecondaryAction": ["action", "appHandle", "elementIndex", "snapshotId"], + "permissionStatus": [], "pressKey": ["appHandle", "key", "modifiers"], + "requestPermission": ["permission"], "screenshot": ["appHandle", "windowId"], "scroll": ["appHandle", "deltaLinesX", "deltaLinesY", "snapshotId", "windowId", "x", "y"], "selectText": ["appHandle", "elementIndex", "prefix", "selectionType", "snapshotId", "suffix", "text"], @@ -26,8 +30,10 @@ enum NativeProtocol { static let stringEnums: [String: [String: Set]] = [ "click": ["modifiers": ["command", "control", "fn", "option", "shift"], "mouseButton": ["left", "middle", "right"]], "drag": ["modifiers": ["command", "control", "fn", "option", "shift"], "mouseButton": ["left", "middle", "right"]], + "openPermissionSettings": ["permission": ["accessibility", "screenRecording"]], "paste": ["format": ["html", "text"]], "pressKey": ["modifiers": ["command", "control", "fn", "option", "shift"]], + "requestPermission": ["permission": ["accessibility", "screenRecording"]], "selectText": ["selectionType": ["cursor_after", "cursor_before", "text"]], ] } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/Permissions.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/Permissions.swift new file mode 100644 index 0000000000..b4a9dcc587 --- /dev/null +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/Permissions.swift @@ -0,0 +1,69 @@ +// System consent is requested only by the Host's user-facing permission controls. +import AppKit +@preconcurrency import ApplicationServices +import Darwin +import Foundation + +@MainActor struct SystemPermissions { + private let launchApplication: JSONValue? + + // Capture the system-reported responsible app before startup completes and the launcher can exit. + init() { launchApplication = Self.responsibleApplication(for: getpid()) } + + func status() -> JSONValue { + var result: [String: JSONValue] = [ + "accessibility": .bool(AXIsProcessTrusted()), + "screenRecording": .bool(CGPreflightScreenCaptureAccess()), + "helper": Self.process(getpid()), + "parent": Self.process(getppid()), + ] + if let launchApplication { result["launchApplication"] = launchApplication } + return .object(result) + } + + func request(_ permission: String) throws -> JSONValue { + switch permission { + case "accessibility": + if !AXIsProcessTrusted() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + case "screenRecording": + if !CGPreflightScreenCaptureAccess() { _ = CGRequestScreenCaptureAccess() } + default: throw RequestParameters.invalid("permission must be accessibility or screenRecording") + } + return status() + } + + func openSettings(_ permission: String) throws -> JSONValue { + let pane: String + switch permission { + case "accessibility": pane = "Privacy_Accessibility" + case "screenRecording": pane = "Privacy_ScreenCapture" + default: throw RequestParameters.invalid("permission must be accessibility or screenRecording") + } + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)"), + NSWorkspace.shared.open(url) else { + throw NativeFailure(code: "SETTINGS_UNAVAILABLE", message: "Open System Settings > Privacy & Security manually") + } + return .object(["opened": .bool(true)]) + } + + /// Resolve macOS process responsibility, including launchers outside the nearest GUI application. + static func responsibleApplication(for pid: Int32) -> JSONValue? { + guard let responsible = NativePlatform.responsiblePID(pid), + let app = NSRunningApplication(processIdentifier: responsible), !app.isTerminated, + let bundle = app.bundleURL else { return nil } + var result: [String: JSONValue] = ["name": .string(bundle.lastPathComponent), "path": .string(bundle.path)] + if let icon = ApplicationCatalog.icon(at: bundle.path) { result["icon"] = .string(icon) } + return .object(result) + } + + private static func process(_ pid: Int32) -> JSONValue { + let path = NativePlatform.executablePath(pid) + var result: [String: JSONValue] = ["pid": .number(Double(pid))] + if let path { result["path"] = .string(path) } + if let name = NSRunningApplication(processIdentifier: pid)?.localizedName { result["name"] = .string(name) } + return .object(result) + } +} diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift index 08bd3b7f6d..9cfb5a1a04 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift @@ -26,6 +26,13 @@ struct RequestParameters: Sendable { guard let value = values[key]?.string else { throw Self.invalid("\(key) must be a string") } return value } + func strings(_ key: String) throws -> [String] { + guard let raw = values[key], case .array(let items) = raw else { throw Self.invalid("\(key) must be a string array") } + return try items.map { value in + guard let path = value.string, !path.isEmpty else { throw Self.invalid("\(key) must contain nonempty strings") } + return path + } + } func optionalString(_ key: String) throws -> String? { contains(key) ? try string(key) : nil } func optionalBoolean(_ key: String) throws -> Bool? { guard let raw = values[key] else { return nil } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift index b7ced2a11d..99ea8634a9 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift @@ -9,6 +9,7 @@ import Darwin private let cancellation: CancellationLatch private let worker: AXWorker private let capture: WindowCapture + private let permissions: SystemPermissions private let clipboard = ClipboardPaste() private let input: JSONLInput private let writer: JSONLWriter @@ -27,11 +28,12 @@ import Darwin private var nextCancelled = false init(configuration: HelperConfiguration, capture: WindowCapture, channel: SharedSocket, - cancellation: CancellationLatch, scheduler: RequestScheduler, writer: JSONLWriter, + cancellation: CancellationLatch, scheduler: RequestScheduler, writer: JSONLWriter, permissions: SystemPermissions, onClose: @escaping @MainActor @Sendable () -> Void, onFatal: @escaping @Sendable (NativeFailure, Double) -> Void) { self.configuration = configuration self.capture = capture + self.permissions = permissions self.cancellation = cancellation self.channel = channel self.scheduler = scheduler @@ -159,11 +161,25 @@ import Darwin } private func execute(_ request: NativeRequest, trace: ScreenshotTrace?) async throws -> JSONValue { let params = try RequestParameters(request) + try cancellation.check() if request.method == "screenshot" { try capture.requireAvailable() } let requiresCatalog = ["getApp", "listApps", "findApps", "typeText", "pressKey"].contains(request.method) let catalog = requiresCatalog ? ApplicationCatalog.snapshot() : ApplicationCatalogSnapshot(applications: [], frontPID: nil) let result: JSONValue - if request.method == "findApps" { + if request.method == "permissionStatus" { + result = permissions.status() + } else if request.method == "applicationIcons" { + var icons: [String: JSONValue] = [:] + for path in try params.strings("paths") { + try cancellation.check() + if let icon = ApplicationCatalog.icon(at: path) { icons[path] = .string(icon) } + } + result = .object(icons) + } else if request.method == "requestPermission" { + result = try permissions.request(params.string("permission")) + } else if request.method == "openPermissionSettings" { + result = try permissions.openSettings(params.string("permission")) + } else if request.method == "findApps" { result = .array(try ApplicationDiscovery.applications(matching: params.string("query"), exact: false, limit: params.integer("maxAppMatches", in: 1...1000), cancellation: cancellation).map { $0.json(catalog: catalog) }) } else { diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperServer.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperServer.swift index 2de82bce2c..253dbfcaa4 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperServer.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperServer.swift @@ -67,6 +67,29 @@ final class NativeSignals { deinit { sources.forEach { $0.cancel() } } } +// Blocking accepts own only Sendable state; server access belongs to the MainActor callback. +private func acceptConnections(listener: SharedSocket, lifetime: SharedHelperLifetime, + onAccept: @escaping @MainActor @Sendable (UUID, SharedSocket, CancellationLatch) -> Void) { + while !lifetime.isStopping { + let descriptor = accept(listener.descriptor, nil, nil) + if descriptor < 0 && errno == EINTR { continue } + guard descriptor >= 0 else { + if !lifetime.isStopping { lifetime.abort(grace: lifetime.shutdownGrace) } + return + } + let channel = SharedSocket(descriptor: descriptor) + var uid: uid_t = 0 + var gid: gid_t = 0 + guard getpeereid(descriptor, &uid, &gid) == 0, uid == getuid() else { channel.shutdown(); continue } + let id = UUID() + let cancellation = CancellationLatch() + guard lifetime.add(id, channel: channel, cancellation: cancellation) else { channel.shutdown(); return } + Task { @MainActor [onAccept, id, channel, cancellation] in + onAccept(id, channel, cancellation) + } + } +} + @MainActor final class SharedHelperServer { private let options: SharedHelperOptions private let versionLock: SharedHelperLock @@ -75,6 +98,7 @@ final class NativeSignals { private let lifetime: SharedHelperLifetime private let scheduler = RequestScheduler() private let capture = WindowCapture() + private let permissions = SystemPermissions() private let instanceID = UUID().uuidString private var connections: [UUID: RequestCoordinator] = [:] private var startupDeadline: NativeDeadline? @@ -99,12 +123,18 @@ final class NativeSignals { func start() throws { let lifetime = self.lifetime - signals = NativeSignals { [weak self] number in - lifetime.abort(grace: lifetime.shutdownGrace, status: 128 + number) - Task { @MainActor in self?.closeConnections(); self?.exitIfEmpty() } + let onStop: @MainActor @Sendable () -> Void = { [weak self] in + guard let self else { return } + self.closeConnections() + self.exitIfEmpty() } - startupDeadline = NativeDeadline(seconds: options.startupTimeout) { [weak self] in - Task { @MainActor in self?.exitIfEmpty() } + signals = NativeSignals { [lifetime, onStop] number in + lifetime.abort(grace: lifetime.shutdownGrace, status: 128 + number) + Task { @MainActor [onStop] in onStop() } + } + let onIdle: @MainActor @Sendable () -> Void = { [weak self] in self?.exitIfEmpty() } + startupDeadline = NativeDeadline(seconds: options.startupTimeout) { [onIdle] in + Task { @MainActor [onIdle] in onIdle() } } let ready = options.ready(pid: getpid(), instanceID: instanceID) let data = try JSONEncoder().encode(ready) @@ -113,38 +143,45 @@ final class NativeSignals { try FileHandle.standardOutput.write(contentsOf: data + Data([10])) try FileHandle.standardOutput.close() let listener = self.listener - DispatchQueue.global(qos: .utility).async { [weak self] in - while !lifetime.isStopping { - let descriptor = accept(listener.descriptor, nil, nil) - if descriptor < 0 && errno == EINTR { continue } - guard descriptor >= 0 else { - if !lifetime.isStopping { lifetime.abort(grace: lifetime.shutdownGrace) } - return - } - let channel = SharedSocket(descriptor: descriptor) - var uid: uid_t = 0 - var gid: gid_t = 0 - guard getpeereid(descriptor, &uid, &gid) == 0, uid == getuid() else { channel.shutdown(); continue } - let id = UUID() - let cancellation = CancellationLatch() - guard lifetime.add(id, channel: channel, cancellation: cancellation) else { channel.shutdown(); return } - Task { @MainActor in self?.admit(id, channel: channel, cancellation: cancellation) } + let onAccept: @MainActor @Sendable (UUID, SharedSocket, CancellationLatch) -> Void = { [weak self] id, channel, cancellation in + guard let self else { + channel.shutdown() + lifetime.remove(id) + return } + self.admit(id, channel: channel, cancellation: cancellation) + } + DispatchQueue.global(qos: .utility).async { [listener, lifetime, onAccept] in + acceptConnections(listener: listener, lifetime: lifetime, onAccept: onAccept) } } private func admit(_ id: UUID, channel: SharedSocket, cancellation: CancellationLatch) { + guard !lifetime.isStopping else { channel.shutdown(); disconnected(id); return } let options = self.options - DispatchQueue.global(qos: .utility).async { [weak self] in + let lifetime = self.lifetime + let onHello: @MainActor @Sendable (HelperConfiguration) async -> Void = { [weak self] configuration in + guard let self else { + channel.shutdown() + lifetime.remove(id) + return + } + await self.connected(id, channel: channel, cancellation: cancellation, configuration: configuration) + } + let onFailure: @MainActor @Sendable () -> Void = { [weak self] in + lifetime.remove(id) + self?.exitIfEmpty() + } + DispatchQueue.global(qos: .utility).async { [options, channel, onHello, onFailure] in do { let configuration = try HelperConfiguration(hello: channel.readHello(timeout: options.startupTimeout), runtimeKey: options.runtimeKey) - Task { @MainActor in await self?.connected(id, channel: channel, cancellation: cancellation, configuration: configuration) } + Task { @MainActor [onHello, configuration] in await onHello(configuration) } } catch { let failure = error as? NativeFailure ?? NativeFailure(code: "NATIVE_PROTOCOL", message: "Invalid shared helper hello") let response = JSONValue.object(["type": .string("error"), "code": .string(failure.code), "message": .string(failure.message)]) if let data = try? JSONEncoder().encode(response) { try? channel.write(data + Data([10]), timeout: options.startupTimeout) } channel.shutdown() - Task { @MainActor in self?.disconnected(id) } + Task { @MainActor [onFailure] in onFailure() } } } } @@ -155,14 +192,18 @@ final class NativeSignals { let writer = JSONLWriter(limit: configuration.maxResponseBytes, channel: channel, writeTimeout: configuration.shutdownGraceSeconds) do { try await writer.write(options.ready(pid: getpid(), instanceID: instanceID)) } catch { channel.shutdown(); disconnected(id); return } + // Signal shutdown can run while the ready frame is being written. + guard !lifetime.isStopping else { channel.shutdown(); disconnected(id); return } let lifetime = self.lifetime + let onFatal: @MainActor @Sendable () -> Void = { [weak self] in self?.closeConnections() } let coordinator = RequestCoordinator(configuration: configuration, capture: capture, channel: channel, cancellation: cancellation, - scheduler: scheduler, writer: writer, onClose: { [weak self] in + scheduler: scheduler, writer: writer, permissions: permissions, onClose: { [weak self] in channel.shutdown() - self?.disconnected(id) - }, onFatal: { [weak self] _, grace in + guard let self else { lifetime.remove(id); return } + self.disconnected(id) + }, onFatal: { [lifetime, onFatal] _, grace in lifetime.abort(grace: grace) - Task { @MainActor in self?.closeConnections() } + Task { @MainActor [onFatal] in onFatal() } }) connections[id] = coordinator startupDeadline?.cancel() diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index 58546d0fd6..d1ec46702e 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -1037,6 +1037,131 @@ "params" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string", + "const": "permissionStatus" + }, + "params": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": [ + "id", + "method", + "params" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string", + "const": "applicationIcons" + }, + "params": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "paths" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "method", + "params" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string", + "const": "requestPermission" + }, + "params": { + "type": "object", + "properties": { + "permission": { + "type": "string", + "enum": [ + "accessibility", + "screenRecording" + ] + } + }, + "required": [ + "permission" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "method", + "params" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string", + "const": "openPermissionSettings" + }, + "params": { + "type": "object", + "properties": { + "permission": { + "type": "string", + "enum": [ + "accessibility", + "screenRecording" + ] + } + }, + "required": [ + "permission" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "method", + "params" + ], + "additionalProperties": false } ] } diff --git a/packages/experimental/cua-macos/native/tests/LauncherFixture.swift b/packages/experimental/cua-macos/native/tests/LauncherFixture.swift new file mode 100644 index 0000000000..ac297f92e5 --- /dev/null +++ b/packages/experimental/cua-macos/native/tests/LauncherFixture.swift @@ -0,0 +1,29 @@ +// An owned GUI launcher with no windows; its child outlives the launch application. +import AppKit +import Foundation +import Darwin + +@MainActor final class Launcher: NSObject, NSApplicationDelegate { + private let child = Process() + func applicationDidFinishLaunching(_ notification: Notification) { + let arguments = Array(CommandLine.arguments.dropFirst()) + guard let executable = arguments.first else { exit(64) } + child.executableURL = URL(fileURLWithPath: executable) + child.arguments = Array(arguments.dropFirst()) + child.standardOutput = FileHandle.standardOutput + child.standardError = FileHandle.standardError + child.terminationHandler = { @Sendable process in exit(process.terminationStatus) } + do { try child.run() } + catch { fputs("Launching fixture child failed: \(error)\n", stderr); exit(1) } + } +} + +@main enum LauncherMain { + @MainActor static func main() { + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let launcher = Launcher() + app.delegate = launcher + withExtendedLifetime(launcher) { app.run() } + } +} diff --git a/packages/experimental/cua-macos/native/tests/launcher_identity_e2e.py b/packages/experimental/cua-macos/native/tests/launcher_identity_e2e.py new file mode 100644 index 0000000000..e8204274d1 --- /dev/null +++ b/packages/experimental/cua-macos/native/tests/launcher_identity_e2e.py @@ -0,0 +1,76 @@ +"""Verify system responsibility across nested GUI launchers and helper reuse; no TCC grants are required.""" +import argparse +import base64 +from contextlib import ExitStack +import hashlib +import json +from pathlib import Path +import plistlib +import shutil +import struct +import subprocess +import tempfile +import uuid + +from native_test_support import BINARY, ROOT +from shared_helper_e2e import Connection, wait_released + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--helper", type=Path, default=BINARY) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="cua-", dir="/tmp") as temporary, ExitStack() as cleanup: + root = Path(temporary) + helper = root / "helper" + shutil.copy2(args.helper.resolve(strict=True), helper) + key = hashlib.sha256(helper.read_bytes()).hexdigest() + runtime = root / "r" + bundle = root / "Visual Studio Code.app" + binaries = bundle / "Contents" / "MacOS" + binaries.mkdir(parents=True) + launcher = binaries / "launcher" + subprocess.run(["xcrun", "swiftc", "-parse-as-library", str(ROOT / "tests/LauncherFixture.swift"), "-o", str(launcher)], + check=True, timeout=60) + nested = root / "Harness Dev.app" + nested_launcher = nested / "Contents" / "MacOS" / "launcher" + nested_launcher.parent.mkdir(parents=True) + shutil.copy2(launcher, nested_launcher) + identity = uuid.uuid4().hex + register = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + for role, application, name in [("owner", bundle, "Code"), ("nested", nested, "Harness Dev")]: + with (application / "Contents" / "Info.plist").open("wb") as output: + plistlib.dump({"CFBundleExecutable": "launcher", + "CFBundleIdentifier": f"dev.dsh.cua.launcher-fixture.{role}-{identity}", + "CFBundleName": name, "CFBundleDisplayName": name, "CFBundlePackageType": "APPL", + "LSUIElement": True}, output) + cleanup.callback(subprocess.run, [register, "-u", str(application)], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + ready_file, stderr_file = root / "ready.json", root / "stderr.log" + # Launch Services establishes the outer app's responsibility; its GUI child inherits it. + subprocess.run(["/usr/bin/open", "-n", "-g", "-W", "--stdout", str(ready_file), "--stderr", str(stderr_file), + str(bundle), "--args", str(nested_launcher), str(helper), "--ensure-shared", "--runtime-key", key, + "--runtime-directory", str(runtime), "--startup-timeout-ms", "10000"], + check=True, capture_output=True, text=True, timeout=25) + ready = json.loads(ready_file.read_text()) + owner = ready["pid"] + cleanup.callback(wait_released, runtime / (key + ".lock"), owner) + first = Connection(runtime / (key + ".sock"), key) + cleanup.callback(first.close) + state = first.call("permissionStatus", {}) + assert state["parent"]["pid"] == 1, state["parent"] + source = state["launchApplication"] + assert source["name"] == bundle.name, source["name"] + assert Path(source["path"]).resolve() == bundle.resolve(), source["path"] + assert source["icon"].startswith("data:image/png;base64,") + image = base64.b64decode(source["icon"].split(",", 1)[1], validate=True) + assert image[:8] == b"\x89PNG\r\n\x1a\n" + assert struct.unpack(">II", image[16:24]) == (64, 64) + second = Connection(runtime / (key + ".sock"), key) + cleanup.callback(second.close) + assert second.call("permissionStatus", {})["launchApplication"] == source + print("PASS: system-responsible app wins over the nearest GUI ancestor and survives launcher exit and helper reuse") + + +if __name__ == "__main__": + main() diff --git a/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py b/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py index bf0292f072..ad71ec72b2 100644 --- a/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py +++ b/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py @@ -5,6 +5,7 @@ Accessibility/Screen Recording grants are required unless --without-desktop is s """ import argparse import concurrent.futures +from contextlib import ExitStack import fcntl import hashlib import json @@ -128,6 +129,15 @@ def main(): assert a.ready == b.ready == ready[0] for client in clients: client.call("protocol-unsupported", {}, error="UNSUPPORTED") + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as invalid: + invalid.settimeout(15) + invalid.connect(str(socket_path)) + invalid.sendall(b'{"type":"hello","protocol":2}\n') + with invalid.makefile("rb") as reader: + failure = json.loads(reader.readline()) + assert failure["type"] == "error" and failure["code"] == "NATIVE_PROTOCOL", failure + assert reader.readline() == b"", "Rejected handshake retained its connection" + b.call("protocol-after-invalid-hello", {}, error="UNSUPPORTED") # Coalesced frames retain socket order, including cancellation before execution starts. a.socket.sendall(b'{"id":"coalesced","method":"listApps","params":{}}\n' b'{"type":"cancel","id":"coalesced"}\n') @@ -218,20 +228,39 @@ def main(): wait_released(lock_path, owner_pid) owner_pid = None - daemon = subprocess.Popen([base[0], "--serve", *base[1:]], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - try: - assert select.select([daemon.stdout], [], [], 15)[0], "Daemon did not report ready" - signalled_ready = json.loads(daemon.stdout.readline()) - assert signalled_ready["type"] == "ready", signalled_ready - daemon.send_signal(signal.SIGTERM) - status = daemon.wait(timeout=15) - assert status == 128 + signal.SIGTERM, (status, daemon.stderr.read()) - wait_released(lock_path, daemon.pid) - finally: - stop(daemon) - daemon.stdout.close() - daemon.stderr.close() - print("PASS: concurrent launch, shared PID, client isolation, cleanup/lock release and SIGTERM") + for shutdown in ["signal", "incomplete-hello", "idle"]: + serve_options = base[1:] if shutdown != "idle" else [*base[1:-1], "100"] + daemon = subprocess.Popen([base[0], "--serve", *serve_options], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + with ExitStack() as connections: + assert select.select([daemon.stdout], [], [], 15)[0], "Daemon did not report ready" + signalled_ready = json.loads(daemon.stdout.readline()) + assert signalled_ready["type"] == "ready", signalled_ready + if shutdown == "incomplete-hello": + pending = connections.enter_context(socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)) + pending.settimeout(15) + pending.connect(str(socket_path)) + pending.sendall(b'{"type":"hello"') + # A later completed handshake observes the accept loop progressing past this socket. + active = Connection(socket_path, key) + connections.callback(active.socket.close) + connections.callback(active.reader.close) + if shutdown != "idle": + daemon.send_signal(signal.SIGTERM) + status = daemon.wait(timeout=15) + expected_status = 0 if shutdown == "idle" else 128 + signal.SIGTERM + assert status == expected_status, (status, daemon.stderr.read()) + wait_released(lock_path, daemon.pid) + # The forced-exit deadline does not unlink these files; require normal cleanup. + assert not socket_path.exists(), "Shutdown left its listening socket" + assert not (runtime / (key + ".ready")).exists(), "Shutdown left its ready record" + if shutdown == "incomplete-hello": + assert pending.recv(1) == b"", "Signal shutdown retained an incomplete handshake" + finally: + stop(daemon) + daemon.stdout.close() + daemon.stderr.close() + print("PASS: concurrent launch, shared PID, client isolation, rejected/incomplete handshakes, cleanup/lock release, SIGTERM and idle exit") finally: errors = [] for client in reversed(clients): diff --git a/packages/experimental/cua-macos/native/tests/swift/PermissionIdentityTests.swift b/packages/experimental/cua-macos/native/tests/swift/PermissionIdentityTests.swift index 6c29e6afa6..58755b15b1 100644 --- a/packages/experimental/cua-macos/native/tests/swift/PermissionIdentityTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/PermissionIdentityTests.swift @@ -1,5 +1,7 @@ // The selected catalog entry must still identify the executable inspected for consent. +import AppKit import Darwin +import Foundation import Testing @testable import CuaNative @@ -22,3 +24,37 @@ import Testing #expect(error.code == "APP_UNAVAILABLE") } } + +@MainActor @Test func applicationIconsUseBoundedNativePNGs() throws { + let encoded = try #require(ApplicationCatalog.icon(at: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder")) + #expect(encoded.hasPrefix("data:image/png;base64,")) + let data = try #require(Data(base64Encoded: String(encoded.dropFirst("data:image/png;base64,".count)))) + let bitmap = try #require(NSBitmapImageRep(data: data)) + #expect(bitmap.pixelsWide == 64) + #expect(bitmap.pixelsHigh == 64) + #expect(encoded.utf8.count <= 65536) +} + +@Test func resolvesTheCurrentProcessResponsibility() throws { + let responsible = try #require(NativePlatform.responsiblePID(getpid())) + #expect(responsible > 0) + #expect(NativePlatform.responsiblePID(responsible) == responsible) +} + +@MainActor @Test func unknownResponsibilityDoesNotInventAnApplication() { + #expect(SystemPermissions.responsibleApplication(for: 1) == nil) + #expect(SystemPermissions.responsibleApplication(for: Int32.max) == nil) + #expect(NativePlatform.responsiblePID(Int32.max) == nil) +} + +@MainActor @Test func permissionStatusKeepsProcessDiagnosticsSeparateFromLaunchMetadata() { + let permissions = SystemPermissions() + let state = permissions.status() + #expect(state["helper"]["pid"] == .number(Double(getpid()))) + #expect(state["parent"]["pid"] == .number(Double(getppid()))) + if let source = state["launchApplication"].object { + #expect(source["name"]?.string?.isEmpty == false) + #expect(source["path"]?.string?.hasSuffix(".app") == true) + } + #expect(permissions.status()["launchApplication"] == state["launchApplication"]) +} diff --git a/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift b/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift index 6ebed6fb7c..611fa6786c 100644 --- a/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift @@ -116,3 +116,9 @@ func rejectsInvalidWindowIDs(_ value: String) throws { #expect(throws: NativeFailure.self) { try params.optionalBoolean("includeMenus") } } } + +@Test(arguments: ["null", "true", "[1]", "[\"\"]"]) +func rejectsMalformedApplicationIconPaths(_ value: String) throws { + let request = try NativeRequest(data: Data("{\"id\":\"icons\",\"method\":\"applicationIcons\",\"params\":{\"paths\":\(value)}}".utf8)) + #expect(throws: NativeFailure.self) { try RequestParameters(request).strings("paths") } +} diff --git a/packages/experimental/cua-macos/package.json b/packages/experimental/cua-macos/package.json index 03d3f568e2..a2cfd494dd 100644 --- a/packages/experimental/cua-macos/package.json +++ b/packages/experimental/cua-macos/package.json @@ -84,5 +84,8 @@ }, "scripts": { "build:native": "swift build --package-path native --configuration release" - } + }, + "os": [ + "darwin" + ] } diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index b2e4e0381d..0147beefbe 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -1,6 +1,6 @@ /** macOS operation guidance supplied with the shared CUA object API. */ /** Instructions for raw operation calls and optional object-API bindings. */ -export const GUIDANCE = `Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly grants access again. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. +export const GUIDANCE = `Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly permits another authorization request. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. Initialize the object API once in persistent PTC. Reuse its variables in later calls; put temporary declarations inside { ... } blocks to avoid redeclaration errors. All object methods are async. getAXState returns text, not a structured state object. Its text separates values and placeholders, and reports subroles, help, URLs, disabled/focused/selected/expanded state and UTF-16 selection ranges when available. Writability is true, false or unknown on relevant controls; omitted state fields do not assert false. selection=unavailable means the reported range exceeds the observed text. Compact output omits ordinary Press/Pick (click), ShowMenu (right click) and ScrollToVisible actions; details=true lists every available action. Use the displayed action name with performSecondaryAction; ambiguous aliases require the complete name from details=true. Truncation reports the reached provider limit. Narrow the window or increase that provider limit before reading again. @@ -37,7 +37,7 @@ App and Window methods: Raw operation tools use { method, params }, including params: {} for parameterless methods. getApp selects without activating the app. Keep applications in the background for observation and routine input. Do not activate or raise an app during initialization, before each action, or merely because another app is foreground. Prefer advertised AX actions. If a window is hidden or minimized, try app.show() or window.show() and read fresh state. show requests unhide and unminimize without activation, raising or switching Spaces; it chooses the explicit, focused, main or sole AX window. A null windowId means no window was selected for restoration. foregroundChanged compares the front app and top window before and after the request; null means unavailable, and delayed focus changes remain possible. If restoration is unsupported or the window remains unusable, including another Space or Stage Manager, explain the needed foreground step, use app.activate() or an advertised raise action, and read fresh state. Completing the task takes priority when foreground access is necessary. Avoid repeated activation when the user switches away; continue background-capable steps and report what requires foreground access. Applications may still change focus in response to input. -Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant macOS Accessibility or Screen Recording access in System Settings. After granting access, observe fresh state before continuing; do not automatically replay prior input. +Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant the required macOS system permissions for macOS Accessibility or Screen Recording. After granting access, observe fresh state before continuing; do not automatically replay prior input. Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. diff --git a/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts b/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts index a8a93b1857..9f8124ff99 100644 --- a/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts +++ b/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts @@ -73,7 +73,7 @@ it.skipIf(process.platform === 'win32')('delivers a valid PNG larger than 4 MiB const configPath = join(root, 'cordis.yml') const configs: Record = { tools: { mode: 'ptc' }, attachments: { dshHome: join(root, 'home') }, workingDirectory: { defaultDirectory: root }, - fs: { cwd: root }, sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: root }, cua: { helperPath, allowedApps: [{ 'id': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'name': 'Fixture', 'executablePath': '/fixture/app' }] }, + fs: { cwd: root }, sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: root }, cua: { agentPreset: false, helperPath, allowedApps: [{ 'id': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'name': 'Fixture', 'executablePath': '/fixture/app' }] }, } await writeFile(configPath, Object.keys(modules).flatMap(name => [ `- name: 'cordis:${name}'`, ...configs[name] === undefined ? [] : [` config: ${JSON.stringify(configs[name])}`], diff --git a/packages/experimental/cua-macos/tests/loader-composition.spec.ts b/packages/experimental/cua-macos/tests/loader-composition.spec.ts index ca4257dba3..f90bb99620 100644 --- a/packages/experimental/cua-macos/tests/loader-composition.spec.ts +++ b/packages/experimental/cua-macos/tests/loader-composition.spec.ts @@ -85,7 +85,7 @@ it.skipIf(process.platform === 'win32')('shares one application between retained await writeFile(configPath, Object.keys(modules).flatMap(name => [ `- name: 'cordis:${name}'`, ...name === 'attachments' ? [' config:', ` dshHome: ${JSON.stringify(root)}`] : [], - ...name === 'cua' ? [' config:', ` helperPath: ${JSON.stringify(helperPath)}`, ' allowedApps: [{"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "name": "Fixture", "executablePath": "/fixture/app"}]'] : [], + ...name === 'cua' ? [' config:', ' agentPreset: false', ` helperPath: ${JSON.stringify(helperPath)}`, ' allowedApps: [{"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "name": "Fixture", "executablePath": "/fixture/app"}]'] : [], ]).join('\n') + '\n') context.baseUrl = pathToFileURL(root).href + '/' await context.plugin(Loader) diff --git a/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts b/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts index 9dd6452bde..b9573c9eca 100644 --- a/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts +++ b/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts @@ -50,7 +50,7 @@ it.skipIf(process.platform === 'win32' || !existsSync(bridge) || !existsSync(boo await ctx.plugin(NodeRuntime, { executionMode: 'persistent-in-memory', launch: { kind: 'node-script', executable: process.execPath, bootstrapPath: bootstrap } }) await ctx.plugin(Loader) ctx.loader.builtins.cua = Cua - const entry = await ctx.loader.create({ name: 'cordis:cua', config: { helperPath, allowedApps: [{ 'id': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'name': 'Fixture', 'executablePath': '/fixture/app' }] } }) + const entry = await ctx.loader.create({ name: 'cordis:cua', config: { agentPreset: false, helperPath, allowedApps: [{ 'id': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'name': 'Fixture', 'executablePath': '/fixture/app' }] } }) await ctx.loader.resolve(entry).fiber!.await() await mountAgentLoopTestHarness(ctx) ctx.llm.registerAdapter(['bridge-fixture'], new VisualAdapter()) diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index 879657018f..220aed84a4 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -18,6 +18,10 @@ describe('native protocol generation', () => { { method: 'paste', params: { appHandle: 'app', text: 'text', pasteReadTimeoutMs: 1000 } }, { method: 'findApps', params: { query: 'Editor', maxAppMatches: 1 } }, { method: 'launchApp', params: { appHandle: 'pending' } }, + { method: 'applicationIcons', params: { paths: ['/Applications/Fixture.app'] } }, + { method: 'permissionStatus', params: {} }, + { method: 'requestPermission', params: { permission: 'accessibility' } }, + { method: 'openPermissionSettings', params: { permission: 'screenRecording' } }, ])('rejects Host-only request data at model admission: $method', (request) => { expect(Request.safeParse(request).success).toBe(false) }) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 9894c157ed..9f3c57851b 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -55,6 +55,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ })) const config = { + agentPreset: false, helperPath: resolve('fixture-helper'), buildTimeoutMs: 300_000, timeoutMs: 10_000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 3000, maxRequestBytes: 1024, maxResponseBytes: 4096, maxClipboardBytes: 8192, pasteReadTimeoutMs: 1000, maxAppMatches: 20, graceMs: 10, textEventIntervalMs: 8, maxClickHoldMs: 7000, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10, } satisfies Omit @@ -99,9 +100,9 @@ async function setup(model?: { provider: string; model: string }) { async function selectApplication(f: Awaited>) { await f.ctx.plugin(UserQuestions) - f.ctx.on('user-questions/request', async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] })) + f.ctx.on('user-questions/request', async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] })) native.request.mockResolvedValueOnce({ appHandle: 'app-one', pid: 17, name: 'Fixture', - authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true } }) + authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: false } }) expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) } @@ -111,7 +112,7 @@ it('rejects a relative helper path before publishing computer-use operations', a await expect(NativeProvider.apply(ctx, { ...config, allowedApps: { get: () => [] }, helperPath: 'relative-helper' })).rejects.toThrow('helperPath must be absolute') }) -it.each(['Allow for this task', 'Deny'])('obtains %s before a stopped application can launch', async (choice) => { +it.each(['Allow for this session', 'Deny'])('obtains %s before a stopped application can launch', async (choice) => { const f = await setup() await f.ctx.plugin(UserQuestions) const asked: PromiseWithResolvers = Promise.withResolvers() @@ -136,7 +137,7 @@ it.each(['Allow for this task', 'Deny'])('obtains %s before a stopped applicatio it('rejects a launch result that changes the approved handle', async () => { const f = await setup() await f.ctx.plugin(UserQuestions) - f.ctx.on('user-questions/request', async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] })) + f.ctx.on('user-questions/request', async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] })) native.request.mockResolvedValueOnce({ appHandle: 'pending-app', pid: null, name: 'Fixture', authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: false, } }).mockResolvedValueOnce({ appHandle: 'another-app', pid: 17, name: 'Fixture' }) @@ -263,6 +264,24 @@ it('keeps Agent disposal pending until its helper cleanup completes', async () = } finally { release.resolve(); await disposing } }) +it('retains the computer-use registration until permission helper cleanup settles', async () => { + const f = await setup() + native.request.mockResolvedValue({ accessibility: false, screenRecording: false, helper: { pid: 9 }, parent: { pid: 8 } }) + await f.ctx.cuaAccess.systemStatus() + const entered = Promise.withResolvers(), release = Promise.withResolvers() + native.dispose.mockImplementation(async () => { entered.resolve(undefined); await release.promise }) + onTestFinished(() => { release.resolve(undefined) }) + const registry = f.ctx.computerUse + const closing = f.provider.dispose() + try { + await entered.promise + expect(registry.providerName).toBe('dsh-cua') + release.resolve(undefined) + await closing + expect(registry.providerName).toBeUndefined() + } finally { release.resolve(undefined); await closing } +}) + it('joins helper cleanup already started by Agent disposal when the provider unloads', async () => { const f = await setup() await f.execute() @@ -503,6 +522,8 @@ it.each([ { owner: 'provider', stage: 'storage' }, { owner: 'agent', stage: 'admission' }, { owner: 'agent', stage: 'storage' }, + { owner: 'revocation', stage: 'admission' }, + { owner: 'revocation', stage: 'storage' }, ])('waits for screenshot $stage and cancels its result during $owner disposal', async ({ owner, stage }) => { const f = await setup({ provider: 'image-fixture', model: 'vision' }) await selectApplication(f) @@ -532,7 +553,9 @@ it.each([ const reached = await Promise.race([entered.promise.then(() => true), pending.then(() => false)]) expect(reached).toBe(true) let closed = false - closing = (owner === 'provider' ? f.provider.dispose() : f.handle.dispose()).then(() => { closed = true }) + closing = (owner === 'provider' ? f.provider.dispose() + : owner === 'agent' ? f.handle.dispose() + : f.ctx.cuaAccess.revoke('a'.repeat(64) as import('@deepseek-ai/dsh-cua/types').CuaApplicationId)).then(() => { closed = true }) await setImmediate() expect(admissionSignal?.aborted).toBe(true) expect(closed).toBe(false) @@ -629,7 +652,6 @@ it('rejects an unsupported native host before starting preparation', async () => expect(native.prepare).not.toHaveBeenCalled() }) - it('stores signed application consent in the current profile and refuses unavailable settings', async () => { const f = await setup() await f.provider.dispose() @@ -655,7 +677,7 @@ it('stores signed application consent in the current profile and refuses unavail await mountRootInclude(f.ctx, join(dir, 'cordis.yml'), readProfilePatches('test', profile)) await f.ctx.loader.await() await f.ctx.plugin(UserQuestions) - const answer = vi.fn(async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] })) + const answer = vi.fn(async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] })) f.ctx.on('user-questions/request', answer) native.request.mockResolvedValue({ appHandle: 'task-app', pid: 17, name: 'Fixture', authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true } }) @@ -666,7 +688,7 @@ it('stores signed application consent in the current profile and refuses unavail await f.ctx.hmr.runExclusive(async () => {}) expect(f.ctx.settings.describe().find(row => row.ns === 'cua')?.autoGenerate).toBe(false) answer.mockResolvedValue({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] }) - const authorization = { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true } + const authorization = { id: 'b'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true } native.request.mockResolvedValue({ appHandle: 'signed-app', pid: 17, name: 'Fixture', authorization }) expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) expect(await readFile(profile.patchPath, 'utf8')).toContain(authorization.id) @@ -677,7 +699,7 @@ it('stores signed application consent in the current profile and refuses unavail expect(revoked.isError).toBe(true) expect(JSON.stringify(revoked.content)).toContain('APP_ACCESS_REVOKED') vi.spyOn(f.ctx.settings, 'describe').mockReturnValue([]) - native.request.mockResolvedValue({ appHandle: 'different-app', pid: 18, name: 'Other fixture', authorization: { ...authorization, id: 'b'.repeat(64) } }) + native.request.mockResolvedValue({ appHandle: 'different-app', pid: 18, name: 'Other fixture', authorization: { ...authorization, id: 'c'.repeat(64) } }) const unavailable = await f.execute('getApp', { selector: 'Fixture' }) expect(unavailable.isError).toBe(true) expect(JSON.stringify(unavailable.content)).toContain('APP_ACCESS_DENIED') diff --git a/packages/experimental/cua-macos/tsconfig.json b/packages/experimental/cua-macos/tsconfig.json index 05a6c912a2..986873b01b 100644 --- a/packages/experimental/cua-macos/tsconfig.json +++ b/packages/experimental/cua-macos/tsconfig.json @@ -12,7 +12,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../cua" + "path": "../cua/tsconfig.host.json" }, { "path": "../../subprocess/subprocess" diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index dba4b8987a..4c76d9b5e3 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/cua/README.md /: - en: aa3d316ef17feae7 - zh: 5a30d743ce257d5b + en: ed2539c6d12f814a + zh: 55a6c9d7f0d21fb7 /deepseek-ai-dsh-cua: en: 0ef7ee882840cf1b zh: 01b9e768eff2d302 @@ -12,14 +12,17 @@ en: f4d9cc48587d62a2 zh: d1bac6c1fe7eb29e /deepseek-ai-dsh-cua/table-of-contents: - en: d152484eb41ac6b4 - zh: 1db86285abf6c499 + en: 4081cf444a77bf20 + zh: def91d58969ec281 /deepseek-ai-dsh-cua/use-this-package: - en: bdb95a169555b365 - zh: f4bdfbba6809b566 + en: ceb54d964dc355b6 + zh: 0892066fbceef7b9 +/deepseek-ai-dsh-cua/use-this-package/permission-controls: + en: be53792edc381ea1 + zh: 037407055d9f34a0 /deepseek-ai-dsh-cua/understand-the-implementation: - en: effcee57d8854c32 - zh: d02c6b76e7249051 + en: 1fde7df44f040131 + zh: 59a842fa402ccb35 /deepseek-ai-dsh-cua/further-exploration: en: b8b0aece886fadaa zh: ecd8c88d601f2179 @@ -39,8 +42,8 @@ en: 2d5df52dafa1d940 zh: 06e815759b9150c3 /deepseek-ai-dsh-cua/known-limitations-and-deferred-work: - en: da6aefcb279aea83 - zh: b45b7bb300b9f792 + en: ae98f1b2718b3b94 + zh: e6bf4e96ae08e669 /deepseek-ai-dsh-cua/known-limitations-and-deferred-work/dev-note: en: 0a1253c6f7b5e8b9 zh: 9f56528562c7c914 diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 241cd52969..8279d70d9b 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -1,6 +1,6 @@ --- description: "Reuse Computer Use application and window objects with authorized, logged platform operations." -kind: "package-library" +kind: "package-reference" --- # @deepseek-ai/dsh-cua @@ -20,6 +20,8 @@ Inspect applications, retain window objects across PTC calls, and send actions t - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) +Application consent uses the existing User Questions options and remains independent of the session tool-approval policy. The provider accepts only one exact offered label without custom text. Native icons accompany permission-page observations and remain outside saved identities and model results. System checks, saved grants and refusals, and icons use separate queries. + ----- @@ -33,12 +35,24 @@ Observation methods emit and return their result. Use `void await app.getAXState `tools.cua` returns operation values directly. Screenshot values retain the encoded image Base64 and add a durable `attachment` reference for the tool renderer; model content contains coordinate metadata and one image, without the encoded bytes. Before capture, the tool requires an attachment store and a resolved model route declaring image input. Admission and storage failures reject the call. The object bridge preserves the screenshot bytes and validated metadata without uploading or emitting the same image again. -Application consent precedes launch, observation, capture, and input. Task access belongs to the selected instance; saved grants require platform-verified executable identity. Revocation closes affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect. +Application consent precedes launch, observation, capture, and input. Session access is kept in memory by session and application identity, survives agent and helper replacement, and expires when DSH restarts or Computer Use is disabled. Saved grants require platform-verified executable identity. Refusal and revocation close affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect. Actions return `dispatched:true, verified:false`; read fresh state to verify the result. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 1000 after a successful window observation. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to native window points. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. `click(target, { holdMs: 800 })` holds the mouse button for 800 milliseconds before release. `holdMs` accepts nonnegative integers and applies to each press of a double-click; zero releases without an additional hold. The provider config `maxClickHoldMs` limits explicit holds and defaults to 5000 ms. Explicit holds use pointer input for both AX indices and coordinates; omission preserves ordinary click behavior. The operation deadline includes the hold, and cancellation or timeout releases the button. Received input cannot be undone; forced helper termination cannot guarantee release. +### Permission controls + +The [Computer Use bundle](../cua-bundle/README.md) mounts this page with its native provider. **Authorize Computer Use** groups the new-session default, PTC execution mode, Accessibility, and Screen Recording inside one border, with dividers between the rows. Each system-permission row shows the system-reported responsible application’s `.app` bundle filename and icon together in a bordered label. **Manage applications Computer Use can access** places the application lists inside a separate bordered group. Choose **Request permission**, find that application in the macOS permission list, and enable its switch; if macOS names another application, follow that prompt. With no detected source, the page directs the user to the application named by macOS. Each system-permission row has one control: **Request permission** when access is missing, or an outlined **Allowed** button with a settings icon after authorization. Clicking it opens the corresponding privacy pane, where the user can revoke access; hovering shows **Open System Settings**. Unknown status uses the same button with an **Unknown** label; pending initial observations retain a single placeholder. The page refreshes on entry, when the window regains focus, and after permission actions. A failed query offers **Retry**. Follow any macOS quit-and-reopen prompt and restart DSH as needed. + +System-permission operations affect the computer running DSH. When accessing DSH remotely, complete authorization on that computer. + +Enabling from the plugin list shows setup guidance when system permissions need attention, with **Later** and **Open settings** actions. Enabling from the detail page displays the permission controls directly. The detail page provides **Set to PTC** under **Default mode for new sessions**; only this action saves the default. Existing sessions keep their preset. **Use persistent mode** in the **PTC execution mode** row separately saves `ptc-runtime.executionMode: persistent-in-memory`. Switching reloads the shared PTC runtime and may interrupt an active call; subsequent calls retain variables and application objects in memory. Already-persistent runtimes show a disabled **Persistent mode enabled** button. + +The page lists always-allowed applications and refusals from any session. A refusal in one session does not cancel another session’s grant or prevent a new session from asking. Session-only grants remain effective within their sessions and are omitted from this page. **Revoke** removes the selected application's access and cancels affected work. Input already delivered to an application cannot be undone; observe the application before any further action. **Allow asking again** clears that application’s refusal records across all sessions; the next application selection still requires a new decision. + +The Host marker has no configurable fields; platform providers own native configuration. + ----- @@ -47,10 +61,20 @@ Actions return `dispatched:true, verified:false`; read fresh state to verify the
    Shared execution and platform ownership +The permission Remote also exposes `ptcConfiguration()` and `enablePersistentPtc()` for the detail-page runtime setting. Reads inspect the existing runtime and editable profile entry. Explicit writes use `configEditor.edit()` to preserve other fields and apply normal Loader reload; an already-persistent runtime is left intact. This setting is separate from system and application authorization. + The provider calls [installCua](src/index.ts) with its prepared helper, connection factory, and guidance. [NativeClient](src/native-client.ts) owns request correlation, byte limits, cancellation acknowledgement, and failure settlement. The platform connector owns startup and connection cleanup. The shared executor retains one connection per Agent and joins in-flight image writes during disposal. +`agentPreset` defaults to `ptc`, restricting tools and guidance to Agents bound to that preset. The provider uses the existing `agent/created` and `agent-preset/selected` events and Agent-owned scopes, including Agents already present when it loads. Provider unload and Agent disposal release those registrations. Preset definitions may register after the provider; a preset name filters bound Agents and supplies no tools until a matching Agent exists. Set `agentPreset: false` for global registration in standalone compositions without presets. The Host continues to own permissions, native connections and cancellation; preset definitions remain unchanged. + [The operation schema](src/protocol.ts) is the authority for model parameters and separately declared Host-only fields. Native implementations enforce target identity, window geometry, resource limits, and operating-system access at execution. No invariant companion is published because this library has no separately projected registry or durable state. + +The root Host entry exports `installCua`, `CuaConfig`, and the Typert-discovered `CuaAccess` service; its `apply()` only marks the optional Client contribution. `/client` loads the permission UI through the existing module loader. Separate Host and Client compiler projects keep React, CSS, and browser registrations out of headless imports. `/api` owns the app/window API and its types; `/bridge`, `/native-client`, and `/protocol` retain runtime and platform consumers. `/types` exports only the four named boundary types required by generated Remote declarations. The Client uses package-local types. `/typert` supplies Host reflection and `/remote` supplies generated browser descriptors and codecs; permission authority remains on the Host. + +The optional browser contribution mounts the native provider's permission Remote and reuses the Plugins activation and configuration slots. The four setup rows remain visible while unknown fields use placeholders. The default-mode form, runtime configuration, system checks, application decisions, and application icons fill in as each query completes. Application names and actions do not wait for icons; icon positions retain their space. User actions run in order without waiting for pending observation queries. Disposal waits for both actions and queries to settle before releasing the separately owned Remote mount. Query errors stay with the affected section, and activation guidance depends only on system checks. Late results after disposal are discarded. The controller consumes the generated `ctx.remote.cuaAccess` namespace directly and handles unsuccessful Remote results in its existing query and action paths. It projects queries into the page and setup dialog; reconnects and updates to the Host-reported settings namespace refresh it. Operation notices live in the shell overlay, so closing the page does not remove them. The provider owns permission checks, persisted decisions, and cancellation; the browser owns no authorization state. + +The detail page reads the effective default (`selectedDefault ?? default`) from the existing `configForms` service. The default button writes only `selectedDefault` through the form's revision-checked settings path. The execution-mode row uses CUA's `ptcConfiguration()` and `enablePersistentPtc()` controls: the Host edits only `ptc-runtime.executionMode` through the existing configuration editor, preserving its ordinary Loader reload lifecycle. Permission checks and runtime state settle independently. Loading, read-only and unavailable settings cannot submit a change; a failed write remains beside the setting for retry.
    ----- @@ -84,6 +108,7 @@ Results append to history. Retaining objects does not rewrite previous messages; +- In-memory session decisions have no capacity limit or eviction. They remain until the provider unloads, so a long-running Host retains entries for every session/application pair that requested access. - Native execution currently uses the macOS provider. Windows support is planned as a separate provider package with a C# helper, retaining the shared app/window API, authorization, snapshot validity, and action-result semantics. Its implementation must document system-specific limitations and distinguish cross-compilation evidence from Windows desktop validation. - Persistent variables do not reserve an application across a sequence of operations. Other Agents and the user can change its state. Hover and observations do not restore the previous pointer position; [continuous interaction](../../../.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md) uses no cross-call ownership. - The package stays in `experimental` until Windows support and platform validation permit promotion to the `computer-use` group. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index 02db952b44..5f3202407e 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -1,6 +1,6 @@ --- description: "复用 Computer Use 应用和窗口对象,通过已授权并记录日志的平台操作完成任务。" -kind: "package-library" +kind: "package-reference" --- # @deepseek-ai/dsh-cua @@ -20,6 +20,8 @@ kind: "package-library" - [已知限制与后续工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) +应用授权使用现有 User Questions 选项,仍独立于会话的工具审批策略。提供方只接受一个准确匹配所给选项的标签,附带自由文本的回答不会授权。本机图标用于权限页展示,不进入保存的身份信息或模型结果。系统权限、持久授权与拒绝记录、图标分别查询。 + ----- @@ -33,12 +35,24 @@ kind: "package-library" `tools.cua` 直接返回操作结果。截图结果保留图片 Base64,并增加供工具渲染使用的持久 `attachment` 引用;模型内容只包含坐标元数据和一张图片,不包含编码字节。截图前要求附件服务可用,并确认当前模型支持图片输入。图片接入或保存失败会拒绝调用。对象桥接保留截图字节和已校验的元数据,不重复上传或输出同一张图片。 -应用授权发生在启动、观察、截图和输入之前。任务许可绑定已选择的应用实例;长期许可要求平台验证可执行文件身份。撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。 +应用授权发生在启动、观察、截图和输入之前。会话许可按会话和应用身份保存在内存中,Agent 或 helper 重建后仍可复用,重启 DSH 或关闭 Computer Use 后失效。长期许可要求平台验证可执行文件身份。拒绝或撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。 动作返回 `dispatched:true, verified:false`;请读取新状态以验证结果。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~1000 的整数比例。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为原生窗口 point。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 `click(target, { holdMs: 800 })` 按住鼠标按钮 800 毫秒后松开。`holdMs` 接受非负整数,双击时应用于每次按下;0 表示按下后立即松开,不额外等待。提供方配置 `maxClickHoldMs` 限制显式按住时长,默认 5000 毫秒。指定时长后,AX 索引和坐标目标都使用鼠标事件;省略时保留普通点击行为。按住时间计入操作超时,取消或超时会松开按钮。已接收的输入无法撤销;辅助进程被强制终止时无法保证松开。 +### 权限控件 + +[Computer Use 组合](../cua-bundle/README.zh.md)将本页与原生 provider 一起挂载。“给 Computer Use 授权”把新会话默认模式、PTC 执行方式、辅助功能和屏幕录制放在同一个边框内,四排之间使用分隔线。两条系统权限要求在能识别责任应用时,把该应用的 `.app` 包名称和图标放在同一个标签中。“管理 Computer Use 可访问的应用”使用独立边框包住应用列表。点击“申请权限”,在 macOS 权限列表中找到该应用并开启对应开关;若 macOS 显示其他名称,以系统提示为准。来源无法识别时,页面引导用户查找系统提示的应用。每条系统权限要求只显示一个控件:未授权时为“申请权限”,已授权时为带设置图标的“已允许”描边按钮,点击直接进入对应隐私设置页,由用户撤销权限,悬停提示“打开系统设置”。无法确认状态时使用同样的按钮,文字为“未确认”。首次查询期间保留一个占位。进入详情页、返回窗口或完成授权操作后会自动刷新;查询失败时可点击“重试”。若系统要求退出并重新打开应用,请按提示操作并重新启动 DSH。 + +系统权限操作作用于运行 DSH 的电脑。远程访问时,请在该电脑上完成授权。 + +从插件列表启用后,系统权限尚未就绪时会显示设置引导,提供“稍后”和“前往设置”。从详情页启用后直接显示权限控件。详情页的“新会话默认模式”提供“设为 PTC”按钮,仅点击此按钮才保存默认值。已有会话保留原 preset。“PTC 执行方式”一行的“设为持久化”单独保存 `ptc-runtime.executionMode: persistent-in-memory`。切换会重载共享 PTC 执行器,可能打断当前调用;后续调用在内存中保留变量和应用对象。已经使用持久化模式时,按钮显示“已设为持久化”并禁用。 + +页面展示“始终允许的应用”和“不允许访问的应用”。后者汇总任一会话的拒绝记录,不取消其他会话已有的授权,也不阻止新会话询问授权。会话授权仍在对应会话中生效,详情页不展示这类应用。“撤销”会移除对应应用的访问许可并取消相关操作。已发送到应用的输入无法撤回,继续操作前需重新观察应用。“允许再次询问”清除该应用在所有会话中的拒绝记录;下次选择应用时仍需用户重新决定。 + +本插件没有配置字段。 + ----- @@ -47,10 +61,20 @@ kind: "package-library"
    共享执行与平台职责 +权限 Remote 还通过 `ptcConfiguration()` 和 `enablePersistentPtc()` 支持详情页的执行方式设置。查询读取现有执行器和可编辑的 profile 条目。显式写入使用 `configEditor.edit()` 保留其他字段并按正常 Loader 规则重载;已是持久化模式的执行器保持不变。此设置独立于系统权限和应用授权。 + 提供方将准备好的 helper、连接工厂和指引传给 [installCua](src/index.ts)。[NativeClient](src/native-client.ts) 负责请求关联、字节上限、取消确认和失败处理。平台连接器负责启动和连接清理。共享执行器为每个 Agent 保留一个连接,并在销毁时等待进行中的图像写入。 +`agentPreset` 默认为 `ptc`,将工具和指引限制到绑定该 preset 的 Agent。提供方使用现有 `agent/created`、`agent-preset/selected` 事件及 Agent 拥有的作用域,也处理启用前已存在的 Agent。提供方卸载和 Agent 销毁都会释放这些注册。preset 定义可以晚于提供方注册;名称用于筛选已绑定的 Agent,在匹配的 Agent 出现前不提供工具。不使用 preset 的独立组合可设置 `agentPreset: false` 来全局注册。Host 仍拥有权限、本机连接和取消操作,preset 定义保持不变。 + [操作 schema](src/protocol.ts) 定义模型参数,并单独声明 Host 专用字段。原生实现在执行时检查目标身份、窗口几何、资源上限和操作系统访问条件。本库没有独立投影的注册表或持久状态,因此不发布 invariant 模块。 + +根 Host 入口导出 `installCua`、`CuaConfig` 和供 Typert 发现的 `CuaAccess` 服务,`apply()` 只标记可选 Client 贡献。`/client` 通过现有模块加载器加载权限界面。Host 和 Client 使用独立编译项目,headless 导入不会加载 React、CSS 或浏览器注册。`/api` 提供应用、窗口 API 及其类型;`/bridge`、`/native-client` 和 `/protocol` 保留运行时和平台消费者。`/types` 仅导出生成 Remote 声明所需的四个命名类型,Client 使用包内类型。`/typert` 提供 Host 反射,`/remote` 提供浏览器描述和编解码,权限判断仍由 Host 执行。 + +可选浏览器插件挂载原生 provider 的权限 Remote,复用插件页的启用引导和配置插槽。四排使用条件始终可见,未加载的字段保留占位。默认模式、执行方式、系统权限、应用列表和应用图标分别查询,结果返回后各自显示。应用名称和操作按钮不等待图标,图标位置保留空位。用户操作按顺序执行,不等待尚未完成的查询;卸载时等待操作和查询全部结束,再释放独立持有的 Remote 挂载。查询错误只显示在对应部分,启用引导只等待系统权限结果。卸载后的迟到结果会被丢弃。控制器直接接收生成的 `ctx.remote.cuaAccess`,在现有查询和操作流程中处理失败响应,并把查询结果投影到页面和设置弹窗;重新连接或 Host 返回的配置 namespace 更新时刷新状态。操作提示挂载到 shell overlay,关闭页面后仍可显示。provider 负责权限检查、持久授权和取消;浏览器不保存授权决定。 + +详情页通过现有 `configForms` 服务读取实际默认值(`selectedDefault ?? default`)。默认模式按钮仅写入 `selectedDefault`,沿用表单带版本检查的设置写入流程。执行方式一行调用 CUA 的 `ptcConfiguration()` 和 `enablePersistentPtc()`:Host 通过现有配置编辑器仅修改 `ptc-runtime.executionMode`,保留普通 Loader 重载规则。权限检查和执行方式状态独立返回。设置仍在加载、只读或不可用时不能提交;写入失败时在设置旁显示错误,供用户重试。
    ----- @@ -84,6 +108,7 @@ kind: "package-library" +- 会话授权决策保存在内存中,没有容量上限或淘汰机制,直到提供方卸载才清空。长期运行的 Host 会保留每个曾请求访问的会话与应用组合。 - 当前原生执行使用 macOS 提供方。Windows 支持计划使用独立的提供方包和 C# helper,保留共享应用与窗口 API、授权、快照有效性和动作结果语义。实现须说明系统特有的限制,并区分交叉编译证据和 Windows 桌面验证。 - 持久变量不会在一组连续操作期间独占应用。其他 Agent 和用户可能改变其状态。 悬停和观察不恢复先前的鼠标位置;[连续交互](../../../.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md)不分配跨调用所有权。 - 本包保留在 `experimental`,Windows 支持和平台验证完成后可迁至 `computer-use` 组。 diff --git a/packages/experimental/cua/package.json b/packages/experimental/cua/package.json index a1fb346e0a..adfdd19789 100644 --- a/packages/experimental/cua/package.json +++ b/packages/experimental/cua/package.json @@ -28,6 +28,18 @@ "types": "./lib/types/bridge.d.ts", "default": "./lib/bridge.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./types": { + "types": "./lib/types/remote-types.d.ts", + "default": "./lib/remote-types.js" + }, "./native-client": { "types": "./lib/types/native-client.d.ts", "default": "./lib/native-client.js" @@ -35,34 +47,39 @@ "./protocol": { "types": "./lib/types/protocol.d.ts", "default": "./lib/protocol.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" } }, "files": [ "lib/index.js", + "lib/client.js", + "lib/remote-types.js", "lib/api.js", "lib/bridge.js", "lib/native-client.js", "lib/protocol.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-computer-use": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-scope": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3", - "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-atomic-write": "workspace:*" + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-computer-use": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-working-directory": "workspace:*", @@ -74,7 +91,6 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-attachment-local": "workspace:*", - "@deepseek-ai/dsh-computer-use": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", @@ -89,11 +105,46 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "sharp": "^0.35.3", "@deepseek-ai/dsh-ptc-runtime": "workspace:*", - "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-config-editor": "workspace:*", "@deepseek-ai/dsh-hmr": "workspace:*", - "@deepseek-ai/cordis-plugin-timer": "workspace:~" + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@testing-library/react": "^16.1.0", + "@deepseek-ai/dsh-loader-smoke": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-layout" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" } } diff --git a/packages/experimental/cua/src/access-types.ts b/packages/experimental/cua/src/access-types.ts new file mode 100644 index 0000000000..1124ff5023 --- /dev/null +++ b/packages/experimental/cua/src/access-types.ts @@ -0,0 +1,30 @@ +/** Client-safe Computer Use permission observations and saved application decisions. */ +import type { CuaApplicationGrant } from './application-types.ts' +export type { CuaApplicationGrant, CuaApplicationId } from './application-types.ts' + +/** macOS privileges used by the native provider. */ +export type CuaSystemPermission = 'accessibility' | 'screenRecording' +/** Observed process; neither the helper nor its parent is assumed to be the TCC owner. */ +export interface CuaPermissionProcess { + pid: number + path?: string | undefined + name?: string | undefined +} +/** Fresh permission observations and startup metadata from the shared native helper. */ +export interface CuaSystemPermissionState { + accessibility: boolean + screenRecording: boolean + helper: CuaPermissionProcess + parent: CuaPermissionProcess + /** Application macOS reports as responsible for the helper, captured at startup; absent when it cannot be identified. */ + launchApplication?: { name: string; path: string; icon?: string | undefined } | undefined +} +/** Saved grants and refused applications, independent of native permission checks and icons. */ +export interface CuaApplicationState { + /** Owning configuration entry for permission updates; null when this composition has no Loader entry. */ + settingsNamespace: string | null + allowedApps: readonly CuaApplicationGrant[] + /** Refusals from any session, excluding saved grants; other sessions may still hold session access. */ + blockedApps: readonly CuaApplicationGrant[] + persistentAvailable: boolean +} diff --git a/packages/experimental/cua/src/access.ts b/packages/experimental/cua/src/access.ts new file mode 100644 index 0000000000..15e1a33b83 --- /dev/null +++ b/packages/experimental/cua/src/access.ts @@ -0,0 +1,167 @@ +/** Browser controls for the provider's application decisions and native permission work. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-config-editor' +import type {} from '@deepseek-ai/dsh-ptc-runtime' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { ApplicationAccess, type AccessOptions } from './application-access.ts' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { z } from 'zod' +import { ApplicationIcon } from './protocol.ts' +import { NativeClient, type NativeClientConfig, type NativeChannelFactory } from './native-client.ts' +import type { CuaApplicationId, CuaApplicationState, CuaSystemPermission, CuaSystemPermissionState } from './access-types.ts' +export { ApplicationAuthorizationResult } from './application-access.ts' +export type * from './access-types.ts' + +interface ControlOptions extends AccessOptions { + native: NativeClientConfig + createChannel: NativeChannelFactory + settingsNamespace: string | null +} +type Control = (method: string, params: Record) => Promise +const permissionProcess = z.object({ pid: z.number().int().positive(), path: z.string().optional(), name: z.string().optional() }) +const systemPermissions = z.object({ + accessibility: z.boolean(), screenRecording: z.boolean(), helper: permissionProcess, parent: permissionProcess, + launchApplication: z.object({ name: z.string().min(1), path: z.string().min(1), icon: ApplicationIcon.optional() }).optional(), +}) + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Native permission controls and executor-owned application consent. */ + cuaAccess: CuaAccess + } +} + +/** Browser controls for provider-owned native permission and application decisions. */ +export class CuaAccess extends TypertRemoteService { + /** + * @param ctx Provider context. + * @param policy Application decision owner. + * @param settingsNamespace Owning configuration entry. + * @param control Serialized native permission dispatch. + */ + constructor(ctx: Context, private readonly policy: ApplicationAccess, + private readonly settingsNamespace: string | null, private readonly control: Control) { super(ctx, 'cuaAccess') } + + /** Read PTC execution state separately from native permission checks. + * @returns Whether the shared runtime retains memory and has an editable profile entry. + */ + @Remote + ptcConfiguration(): { persistent: boolean; writable: boolean } { + return { + persistent: this.ctx.get('ptcRuntime')?.programFormat === 'repl', + writable: this.ctx.get('configEditor')?.entries().some(entry => entry.options.id === 'ptc-runtime') ?? false, + } + } + + /** Persist in-memory PTC execution through the existing profile editor and Loader lifecycle. + * @returns Completion after the shared runtime reload; already-persistent runtimes are left intact. + */ + @Remote + async enablePersistentPtc(): Promise { + if (this.ptcConfiguration().persistent) return + const editor = this.ctx.get('configEditor') + const entry = editor?.entries().find(entry => entry.options.id === 'ptc-runtime') + if (editor === undefined || entry === undefined) throw new Error('PTC runtime has no editable profile entry') + await editor.edit(entry, current => ({ ...current, executionMode: 'persistent-in-memory' })) + } + + /** + * Read application decisions without waiting for the native helper. + * @returns Saved grants and refused applications with their persistence owner. + */ + @Remote + applications(): CuaApplicationState { + return { settingsNamespace: this.settingsNamespace, ...this.policy.status() } + } + + /** + * Inspect system privileges independently of application decisions and icons. + * @returns Fresh native preflight and launch metadata. + */ + @Remote + async systemStatus(): Promise { + return systemPermissions.parse(await this.control('permissionStatus', {})) + } + + /** + * Load presentation icons separately from application decisions. + * @returns Optional native PNG icons keyed by executable path for saved and blocked applications. + */ + @Remote + async applicationIcons(): Promise> { + const state = this.policy.status() + const paths = [...new Set([...state.allowedApps, ...state.blockedApps].map(app => app.executablePath))] + return paths.length === 0 ? {} : z.record(z.string(), ApplicationIcon).parse(await this.control('applicationIcons', { paths })) + } + + /** + * Request a system privilege explicitly selected by the user. + * @param permission macOS privilege to request. + * @returns Preflight state after requesting; a false value never implies consent. + */ + @Remote + async requestPermission(permission: CuaSystemPermission): Promise { + return systemPermissions.parse(await this.control('requestPermission', { permission })) + } + + /** + * Open the macOS privacy pane selected by the user. + * @param permission Corresponding system privilege. + * @returns Completion after macOS accepted the open request. + */ + @Remote + async openSettings(permission: CuaSystemPermission): Promise { + z.object({ opened: z.literal(true) }).parse(await this.control('openPermissionSettings', { permission })) + } + + /** + * Remove saved and session access and join affected operations. + * @param id Application identity displayed on the permission page. + * @returns Completion after persistence and cancellation; dispatched input cannot be undone. + */ + @Remote + revoke(id: CuaApplicationId): Promise { return this.policy.revoke(id) } + + /** + * Clear this application's refusals across sessions so each session may ask for access again. + * @param id Refused or revoked application displayed on the permission page. + * @returns Completion after clearing refusals; no permission is granted and no action is replayed. + */ + /* oxlint-disable typescript/require-await -- Remote callers receive policy failures as rejected promises. */ + @Remote + async retryApproval(id: CuaApplicationId): Promise { this.policy.retryApproval(id) } + /* oxlint-enable typescript/require-await */ +} + +/** + * Install human-facing controls while keeping executor capabilities private to the provider. + * @param ctx Provider context that owns decisions and operation lifetimes. + * @param options Native transport and profile persistence. + * @returns Consent selection, guarded dispatch, and task cleanup capabilities. + */ +export function installCuaAccess(ctx: Context, options: ControlOptions): Pick { + const policy = new ApplicationAccess(ctx, options) + const lifetime = new AbortController() + let client: NativeClient | undefined + let tail: Promise = Promise.resolve() + const control: Control = (method, params) => { + lifetime.signal.throwIfAborted() + const perform = async () => { + lifetime.signal.throwIfAborted() + if (client?.disposed) await client.dispose() + lifetime.signal.throwIfAborted() + if (!client || client.disposed) client = new NativeClient(ctx, options.native, options.createChannel) + return client.request(method, params, lifetime.signal) + } + const operation = tail.then(perform, perform) + tail = operation + return operation + } + const dispose = ctx.effect(() => async () => { + lifetime.abort() + await Promise.allSettled([policy.dispose(), tail]) + await client?.dispose() + }, 'cua-access.dispose') + new CuaAccess(ctx, policy, options.settingsNamespace, control) + return { select: policy.select.bind(policy), run: policy.run.bind(policy), forget: policy.forget.bind(policy), dispose } +} diff --git a/packages/experimental/cua/src/application-access.ts b/packages/experimental/cua/src/application-access.ts index 1412e0cc99..4b34223012 100644 --- a/packages/experimental/cua/src/application-access.ts +++ b/packages/experimental/cua/src/application-access.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { NativeError, type NativeClient } from './native-client.ts' import type { CuaAppHandle } from './types.ts' import type { CuaApplicationGrant, CuaApplicationId } from './application-types.ts' +import type { CuaApplicationState } from './access-types.ts' /** Native identity admission occurs before any application observation or input. */ export const ApplicationAuthorizationResult = z.object({ @@ -19,7 +20,7 @@ export interface AccessOptions { } interface Selection { identity: CuaApplicationGrant - scope: 'task' | 'persistent' + scope: 'session' | 'persistent' lifetime: AbortController } interface ClientSelections { @@ -37,9 +38,9 @@ interface ActiveOperation { /** Owns application consent and cancels operations when saved access is removed. */ export class ApplicationAccess { private readonly clients = new Map() - private readonly rejected = new Map>() + private readonly decisions = new Map>() private readonly active = new Set() - private readonly selections = new WeakMap>>() + private readonly selections = new Map>>() private readonly lifetime = new AbortController() private mutationTail: Promise = Promise.resolve() private disposal: Promise | undefined @@ -59,13 +60,66 @@ export class ApplicationAccess { this.lifetime.abort() await Promise.allSettled([this.mutationTail, ...[...this.active].map(operation => operation.done.promise)]) this.clients.clear() - this.rejected.clear() + this.decisions.clear() })() } + /** + * Project application decisions without requesting native permissions. + * @returns Current saved grants and refused applications for the permission page. + */ + status(): Omit { + const allowedApps = [...this.options.grants()] + const blockedApps = new Map() + for (const decisions of this.decisions.values()) for (const [id, decision] of decisions) { + if (decision.allowed || allowedApps.some(grant => grant.id === id)) continue + blockedApps.set(id, decision.identity) + } + return { allowedApps, + blockedApps: [...blockedApps.values()], persistentAvailable: this.options.save !== undefined } + } + + /** + * Remove saved and session consent and cancel affected native operations. + * @param id Application identity shown in the permission page. + * @returns Completion after persistence and affected operations settle; already dispatched input cannot be undone. + */ + async revoke(id: CuaApplicationId): Promise { + this.lifetime.signal.throwIfAborted() + let clients: readonly NativeClient[] = [] + await this.mutate(async () => { + const grants = this.options.grants() + if (grants.some(grant => grant.id === id)) { + const save = this.options.save + if (!save) throw new Error('Application permissions cannot be saved in this composition') + await save(grants.filter(grant => grant.id !== id)) + } + clients = this.invalidate(id) + }) + const results = await Promise.allSettled([ + ...clients.map(client => client.dispose()), + ...[...this.active].filter(operation => operation.identity.id === id).map(operation => operation.done.promise), + ]) + const errors: unknown[] = [] + for (const result of results) if (result.status === 'rejected') errors.push(result.reason) + if (errors.length) throw new AggregateError(errors, 'Application access was revoked, but helper cleanup failed') + } + + /** + * Clear explicit refusal; the next getApp still requires a new user decision. + * @param id Application identity selected by the human. + */ + retryApproval(id: CuaApplicationId): void { + this.lifetime.signal.throwIfAborted() + for (const decisions of this.decisions.values()) if (decisions.get(id)?.allowed === false) decisions.delete(id) + for (const state of this.clients.values()) for (const [handle, selected] of state.handles) { + if (selected.identity.id === id && selected.lifetime.signal.aborted) state.handles.delete(handle) + } + } + /** * Obtain consent for a freshly selected native handle. Free text never grants access. - * @param agent Exact live task requesting access. + * @param agent Live agent whose session owns the decision. * @param client Helper that issued the handle. * @param handle Helper handle for a running instance or an approved pending launch. * @param identity Native executable/signing identity. @@ -79,19 +133,21 @@ export class ApplicationAccess { const existing = this.clients.get(client)?.handles.get(handle) if (!client.disposed && existing && this.permitted(existing)) return const saved = identity.persistable && this.options.grants().some(grant => grant.id === identity.id) - let scope: Selection['scope'] = 'persistent' - if (!saved && this.rejected.get(agent)?.has(identity.id)) throw this.denied(identity) + const decision = this.decisions.get(agent.id)?.get(identity.id) + let scope: Selection['scope'] = saved ? 'persistent' : 'session' + if (!saved && decision?.allowed === false) throw this.denied(identity) if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles') - if (!saved) { + if (!saved && !decision?.allowed) { const questions = this.ctx.get('userQuestions') if (!questions) throw new NativeError('APP_APPROVAL_UNAVAILABLE', 'No human answerer is available for application access') - const options = [{ label: 'Allow for this task' }, { label: 'Deny' }] + const options = [{ label: 'Allow for this session' }, { label: 'Deny' }] if (identity.persistable && this.options.save) options.push({ label: 'Always allow' }) let result: AskUserQuestionAnswer try { result = await questions.ask({ agent, signal, questions: [{ id: 'cua-app-access', question: `Allow Computer Use to launch if needed, read, capture, and control ${identity.name}?`, - detail: `Application: ${identity.executablePath}\nTask access applies to the selected application instance, including its launch if needed. macOS permissions and approval of sensitive actions remain separate. Saved access can be revoked by removing its allowedApps entry from this provider configuration. Task access ends with the task.`, options }] }) + detail: 'Session access lasts until DSH restarts or Computer Use is disabled.', + options }] }) } catch (error) { if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { this.invalidate(identity.id, agent) @@ -105,8 +161,9 @@ export class ApplicationAccess { signal.throwIfAborted() const answer = result.answers.find(answer => answer.id === 'cua-app-access') const chosen = answer && !answer.custom?.trim() && answer.selected.length === 1 ? answer.selected[0] : undefined - if (chosen === 'Allow for this task') scope = 'task' + if (chosen === 'Allow for this session') scope = 'session' else if (chosen === 'Always allow' && identity.persistable && this.options.save) { + scope = 'persistent' const { persistable: _persistable, ...grant } = identity await this.persist((grants) => { signal.throwIfAborted() @@ -129,7 +186,8 @@ export class ApplicationAccess { } state.handles.set(handle, { identity: grant, scope, lifetime: new AbortController() }) this.clients.set(client, state) - this.rejected.get(agent)?.delete(identity.id) + if (scope === 'session') this.decide(agent.id, grant, true) + else this.decisions.get(agent.id)?.delete(identity.id) })) } @@ -155,11 +213,14 @@ export class ApplicationAccess { } private async selectInOrder(agent: Agent, id: CuaApplicationId, select: () => Promise): Promise { - const selections = this.selections.get(agent) ?? new Map>() - this.selections.set(agent, selections) + const selections = this.selections.get(agent.id) ?? new Map>() + this.selections.set(agent.id, selections) const operation = (selections.get(id) ?? Promise.resolve()).then(select, select) selections.set(id, operation) - try { await operation } finally { if (selections.get(id) === operation) selections.delete(id) } + try { await operation } finally { + if (selections.get(id) === operation) selections.delete(id) + if (selections.size === 0) this.selections.delete(agent.id) + } } private async withOperation(agent: Agent, client: NativeClient, identity: CuaApplicationGrant, signal: AbortSignal, @@ -172,6 +233,9 @@ export class ApplicationAccess { const result = await dispatch(lifetime) lifetime.throwIfAborted() return result + } catch (error) { + if (operation.controller.signal.aborted) throw operation.controller.signal.reason + throw error } finally { this.active.delete(operation) operation.done.resolve() @@ -179,40 +243,44 @@ export class ApplicationAccess { } /** - * Forget the disposed task's transient decisions and selected handles. - * @param agent Disposed task whose transient decisions are released. + * Release the disposed agent's handles while retaining its session decisions. + * @param agent Disposed agent whose selected handles are released. */ forget(agent: Agent): void { for (const [client, state] of this.clients) if (state.agent === agent) this.clients.delete(client) - this.rejected.delete(agent) } private permitted(selection: Selection): boolean { return !selection.lifetime.signal.aborted - && (selection.scope === 'task' || this.options.grants().some(grant => grant.id === selection.identity.id)) + && (selection.scope === 'session' || this.options.grants().some(grant => grant.id === selection.identity.id)) } private denied(identity: CuaApplicationGrant): NativeError { return new NativeError('APP_ACCESS_DENIED', `The user denied access to ${identity.name}. Do not retry or use another tool to bypass this decision.`) } - private reject(agent: Agent, identity: CuaApplicationGrant): void { - const rejected = this.rejected.get(agent) ?? new Map() - rejected.set(identity.id, { id: identity.id, name: identity.name, executablePath: identity.executablePath }) - this.rejected.set(agent, rejected) + private decide(sessionId: Agent['id'], identity: CuaApplicationGrant, allowed: boolean): void { + const decisions = this.decisions.get(sessionId) ?? new Map() + decisions.set(identity.id, { identity: { id: identity.id, name: identity.name, executablePath: identity.executablePath }, allowed }) + this.decisions.set(sessionId, decisions) } - private invalidate(id: CuaApplicationId, agent?: Agent): void { + private invalidate(id: CuaApplicationId, agent?: Agent): NativeClient[] { const clients = new Set() + for (const [sessionId, decisions] of this.decisions) { + const decision = decisions.get(id) + if (decision && (agent === undefined || sessionId === agent.id)) this.decide(sessionId, decision.identity, false) + } for (const [client, state] of this.clients) for (const selection of state.handles.values()) if (selection.identity.id === id - && (agent === undefined || state.agent === agent)) { + && (agent === undefined || state.agent.id === agent.id)) { selection.lifetime.abort(new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application')) - this.reject(state.agent, selection.identity) + this.decide(state.agent.id, selection.identity, false) clients.add(client) } - for (const operation of this.active) if (operation.identity.id === id && (agent === undefined || operation.agent === agent)) { - this.reject(operation.agent, operation.identity) + for (const operation of this.active) if (operation.identity.id === id && (agent === undefined || operation.agent.id === agent.id)) { + this.decide(operation.agent.id, operation.identity, false) operation.controller.abort(this.denied(operation.identity)) clients.add(operation.client) } for (const client of clients) client.invalidate() + return [...clients] } private reconcile(): void { for (const state of this.clients.values()) for (const selection of state.handles.values()) { diff --git a/packages/experimental/cua/src/client/PermissionPanel.module.css b/packages/experimental/cua/src/client/PermissionPanel.module.css new file mode 100644 index 0000000000..d93d715e03 --- /dev/null +++ b/packages/experimental/cua/src/client/PermissionPanel.module.css @@ -0,0 +1,48 @@ +.panels { display: grid; gap: 24px; margin: 18px 0; font-size: 13px; } +.panel { display: grid; gap: 12px; min-width: 0; } +.card { min-width: 0; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md); } +.card > * + * { border-top: 0.5px solid var(--dsw-alias-border-l1); } +.panel p { margin: 0; color: var(--dsw-alias-label-secondary); line-height: 1.6; } +.panel h3 { margin: 0; font-size: 14px; font-weight: 500; } +.panel h4 { margin: 0; font-size: 13px; font-weight: 500; color: var(--dsw-alias-label-secondary); } +.permission, .apps li { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.permission, .group, .emptyState, .notice { padding: 16px; } +.permission { min-height: 80px; box-sizing: border-box; } +.defaultSetting > p { margin: 0 16px 16px; } +.permission > div:first-child, .apps li > div { flex: 1; min-width: 140px; } +.notice { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.group { display: grid; gap: 4px; } +.apps { display: grid; padding: 0; margin: 0; list-style: none; } +.apps li { padding: 12px 0; } +.apps li:last-child { padding-bottom: 0; } +.panel strong { font-weight: 500; } +.panel .error { color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; } +.application { display: flex; align-items: center; gap: 10px; } +.application strong { min-width: 0; overflow-wrap: anywhere; } +.application img { flex-shrink: 0; object-fit: contain; } +.applicationBadge { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 2px 6px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-sm); + color: var(--dsw-alias-label-primary); + vertical-align: middle; +} +.applicationBadge img { flex-shrink: 0; object-fit: contain; } +.applicationBadge > span { min-width: 0; overflow-wrap: anywhere; } +.permissionAction, .buttonPlaceholder { width: 168px; flex-shrink: 0; } +.permissionAction[data-configured]:disabled { opacity: 1; color: var(--dsw-alias-label-primary); cursor: default; } +.buttonPlaceholder, .appPlaceholder, .appsPlaceholder { + display: inline-block; + max-width: 100%; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-bg-skeleton); +} +.buttonPlaceholder { height: 28px; } +.appPlaceholder { width: 160px; height: 20px; } +.appsPlaceholder { width: 240px; height: 40px; } + +.iconPlaceholder { display: inline-block; width: 32px; height: 32px; flex-shrink: 0; } diff --git a/packages/experimental/cua/src/client/PermissionPanel.tsx b/packages/experimental/cua/src/client/PermissionPanel.tsx new file mode 100644 index 0000000000..5abe3d5db9 --- /dev/null +++ b/packages/experimental/cua/src/client/PermissionPanel.tsx @@ -0,0 +1,214 @@ +/** Native permission status and revocable application consent on the existing plugin page. */ +import { useEffect, useState } from 'react' +import { Button, IconSettingsOutlineRegular, Modal, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { CuaApplicationGrant, CuaSystemPermissionState } from '../access-types.ts' +import type { PermissionFace } from './controller.ts' +import type { NS } from './locales.ts' +import css from './PermissionPanel.module.css' + +/** Renderer-bound permission observation and localized controls. */ +export type PermissionProps = InjectFace & PropsLocale + +/** Permission guidance only navigates to the existing bundle details. */ +export type PermissionSetupProps = PermissionProps + & Pick, 'onDismiss' | 'onOpenDetails'> + +type DefaultPresetProps = PropsLocale & InjectFace<{ + hooks: { cuaDefaultPreset: ConfigForm<{ default: string; selectedDefault?: string }> } + setPtcDefault: () => Promise +}> + +type PersistentPtcProps = PermissionProps & { setPersistentPtc: () => Promise } + +/** The detail page owns explicit preset and execution-mode settings. */ +export type PermissionPanelProps = PermissionProps & DefaultPresetProps & PersistentPtcProps + +function DefaultPresetSetting(props: DefaultPresetProps) { + const preset = props.useCuaDefaultPreset(value => value) + return +} + +function PersistentPtcSetting(props: PersistentPtcProps) { + const runtime = props.useCuaPermissions(value => value.runtime) + return +} + +function ConfigurationSetting({ kind, status, loading, writable, configured, save, t }: PropsLocale & { + kind: 'default' | 'persistent' + status: 'loading' | 'ready' | 'unavailable' + loading: boolean + writable: boolean + configured: boolean + save: () => Promise +}) { + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const submit = async (): Promise => { + setSaving(true) + setError(null) + try { + if (!await save()) setError(t(`${kind}NotSaved`)) + } catch (error) { + setError(t(`${kind}Failed`, { message: error instanceof Error ? error.message : String(error) })) + } finally { setSaving(false) } + } + return
    +
    +
    {t(`${kind}Title`)}

    {t(`${kind}Body`)}

    + {loading ? + : } +
    + {!configured && (status === 'unavailable' ?

    {t(`${kind}Unavailable`)}

    + : status === 'ready' && !writable &&

    {t(`${kind}ReadOnly`)}

    )} + {error !== null &&

    {error}

    } +
    +} + +function Applications({ title, apps, icons, onAction, label, disabled }: { + title: string + apps: readonly CuaApplicationGrant[] + icons: Record | null + disabled: boolean + label: string + onAction: PermissionProps['revoke'] +}) { + return
    +

    {title}

    +
      {apps.map(app =>
    • +
      + {icons?.[app.executablePath] !== undefined ? + : } + {app.name} +
      + +
    • )}
    +
    +} + +function PermissionDescription({ application, permission, loading, t }: Pick & { + application: CuaSystemPermissionState['launchApplication'] + permission: 'accessibility' | 'screenRecording' + loading: boolean +}) { + if (!loading && application === undefined) return

    {t(permission === 'accessibility' ? 'accessibilityUse' : 'screenRecordingUse')}

    + const [before, after] = t(permission === 'accessibility' ? 'accessibilityPermission' : 'screenRecordingPermission').split('{application}') + return

    + {before} + + {application === undefined ? : <> + {application.icon !== undefined && } + {application.name} + } + + {after} +

    +} + +function PermissionAction({ permission, granted, loading, busy, request, openSettings, t }: Pick & { + permission: 'accessibility' | 'screenRecording' + granted: boolean | null + loading: boolean + busy: boolean +}) { + if (loading) return + if (granted === false) return + return +} + +/** @param props Host observation and explicit user actions. @returns Permission controls without automatic system prompts. */ +export function PermissionPanel(props: PermissionPanelProps) { + const { t, refresh } = props + const state = props.useCuaPermissions(value => value) + const observed = state.system.value + const apps = state.applications.value + const busy = state.system.busy + const appsBusy = state.applications.busy + const loading = observed === null && (busy || state.system.error === null) + const application = !busy && state.system.error !== null ? undefined : observed?.launchApplication + useEffect(() => { + refresh() + window.addEventListener('focus', refresh) + return () => { window.removeEventListener('focus', refresh) } + }, [refresh]) + return
    +
    +

    {t('title')}

    +
    + + + {!busy && state.system.error !== null &&
    + {t('queryFailed', { message: state.system.error })} + +
    } + {(['accessibility', 'screenRecording'] as const).map(permission =>
    +
    + {t(permission)} + +
    + +
    )} +
    + {observed !== null && (!observed.accessibility || !observed.screenRecording) &&

    + {t(application === undefined ? 'permissionHintUnknown' : 'permissionHint')} +

    } +
    +
    +

    {t('applicationsTitle')}

    +
    + {(['applications', 'icons'] as const).map(key => !state[key].busy && state[key].error !== null &&
    + {t(key === 'applications' ? 'applicationsFailed' : 'iconsFailed', { message: state[key].error })} + +
    )} + {apps === null ?
    : <> + {apps.allowedApps.length === 0 && apps.blockedApps.length === 0 &&

    {t('noApps')}

    } + {apps.allowedApps.length > 0 && } + {apps.blockedApps.length > 0 && } + } +
    +
    +
    +} + +/** + * @param props Permission observation and activation navigation callbacks. + * @returns Setup guidance when system permissions need attention. + */ +export function PermissionSetup({ t, refresh, useCuaPermissions, onDismiss, onOpenDetails }: PermissionSetupProps) { + const state = useCuaPermissions(value => value.system) + useEffect(() => { refresh() }, [refresh]) + const ready = state.value !== null && state.error === null + && state.value.accessibility && state.value.screenRecording + useEffect(() => { if (ready && !state.busy) onDismiss() }, [ready, state.busy, onDismiss]) + return + + + }> +

    {t(state.error === null ? 'setupBody' : 'setupQueryFailed')}

    +
    +} + +/** @param props Shared operation outcome, independent of page lifetime. @returns The existing global toast presentation. */ +export function PermissionToast(props: PermissionProps) { + const toast = props.useCuaPermissions(value => value.toast) + if (!toast) return null + return +} diff --git a/packages/experimental/cua/src/client/controller.ts b/packages/experimental/cua/src/client/controller.ts new file mode 100644 index 0000000000..0ecdbdf4d0 --- /dev/null +++ b/packages/experimental/cua/src/client/controller.ts @@ -0,0 +1,121 @@ +/** One query projection for the permission page and activation guidance. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-cua/remote' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { CuaApplicationId, CuaApplicationState, CuaSystemPermission, CuaSystemPermissionState } from '../access-types.ts' + +interface PermissionData { + runtime: { persistent: boolean; writable: boolean } + system: CuaSystemPermissionState + applications: CuaApplicationState + icons: Record +} +/** Query failures retain the last observation without presenting it as current. */ +interface Query { + value: T | null + busy: boolean + error: string | null +} +/** Independently settled observations; the overall busy flag follows the latest refresh. */ +export type PermissionView = { [K in keyof PermissionData]: Query } & { + busy: boolean + toast: { id: number; kind: 'requested' | 'opened' | 'revoked' | 'retryAllowed' } | { id: number; kind: 'failed'; message: string } | null +} +/** Slot actions and the renderer-bound permission projection. */ +export interface PermissionFace { + hooks: { cuaPermissions: SnapshotStore } + refresh: () => void + request: (permission: CuaSystemPermission) => void + openSettings: (permission: CuaSystemPermission) => void + revoke: (id: CuaApplicationId) => void + retryApproval: (id: CuaApplicationId) => void + dismissToast: () => void +} + +/** Serializes user actions and drops superseded refresh results or results arriving after disposal. */ +export class PermissionController { + /** Last observed permissions and the lifetime of the current refresh/action. */ + readonly state = createSnapshotStore({ + runtime: { value: null, busy: false, error: null }, + system: { value: null, busy: false, error: null }, + applications: { value: null, busy: false, error: null }, + icons: { value: null, busy: false, error: null }, + busy: false, toast: null, + }) + private tail: Promise = Promise.resolve() + private reads: Promise = Promise.resolve() + private closed = false + private sequence = 0 + /** @param remote Generated CUA Remote namespace; failed results are handled with query and action failures. */ + constructor(private readonly remote: Context['remote']['cuaAccess']) {} + /** + * Bind the permission projection to its user actions. + * @returns Reactive state and controls shared by the page, prompt, and toast host. + */ + face(): PermissionFace { + return { + hooks: { cuaPermissions: this.state }, + refresh: () => { this.enqueue() }, + request: (permission) => { this.enqueue(() => this.remote.requestPermission(permission), 'requested') }, + openSettings: (permission) => { this.enqueue(() => this.remote.openSettings(permission), 'opened') }, + revoke: (id) => { this.enqueue(() => this.remote.revoke(id), 'revoked') }, + retryApproval: (id) => { this.enqueue(() => this.remote.retryApproval(id), 'retryAllowed') }, + dismissToast: () => { if (!this.closed) this.state.update((state) => { state.toast = null }) }, + } + } + /** Prevent publication after UI withdrawal and join the accepted Remote work. */ + async dispose(): Promise { this.closed = true; await this.tail; await this.reads } + private enqueue(action?: () => Promise>, kind?: 'requested' | 'opened' | 'revoked' | 'retryAllowed'): void { + if (this.closed) return + const current = () => !this.closed + const sequence = ++this.sequence + this.state.update((state) => { + state.busy = true + for (const key of ['runtime', 'system', 'applications', 'icons'] as const) state[key].busy = true + }) + this.tail = this.tail.then(async () => { + if (!current()) return + try { + if (action) { + const result = await action() + if (!result.ok) throw result.error + } + if (!current()) return + if (kind) this.state.update((state) => { state.toast = { id: sequence, kind } }) + } catch (error) { + if (!current()) return + const message = error instanceof Error ? error.message : String(error) + this.state.update((state) => { state.toast = { id: sequence, kind: 'failed', message } }) + } + const latest = () => current() && this.sequence === sequence + const refresh = Promise.all([ + this.read('runtime', this.remote.ptcConfiguration, latest), + this.read('system', this.remote.systemStatus, latest), + this.read('applications', this.remote.applications, latest), + this.read('icons', this.remote.applicationIcons, latest), + ]).then(() => { + if (latest()) this.state.update((state) => { state.busy = false }) + }) + this.reads = Promise.all([this.reads, refresh]).then(() => {}) + }) + } + + private async read( + key: K, read: () => Promise>, current: () => boolean, + ): Promise { + if (!current()) return + try { + const result = await read() + if (!current()) return + if (!result.ok) throw result.error + this.state.update((state) => { state[key].value = result.value; state[key].error = null; state[key].busy = false }) + } catch (error) { + if (!current()) return + this.state.update((state) => { + state[key].error = error instanceof Error ? error.message : String(error); state[key].busy = false + }) + } + } +} diff --git a/packages/experimental/cua/src/client/index.ts b/packages/experimental/cua/src/client/index.ts new file mode 100644 index 0000000000..a4b192ce58 --- /dev/null +++ b/packages/experimental/cua/src/client/index.ts @@ -0,0 +1,15 @@ +/** Bind the source-owned permission UI to its generated Remote contribution. */ +import type { Context } from '@deepseek-ai/cordis' +import cuaRemote from '@deepseek-ai/dsh-cua/remote' +import { mountCuaPermissions } from './mount.ts' + +export { inject } from './mount.ts' + +/** + * Activate the optional Computer Use permission controls. + * @param ctx Browser plugin context. + * @returns Cleanup of the UI and Remote contribution. + */ +export async function apply(ctx: Context): Promise<() => Promise> { + return mountCuaPermissions(ctx, cuaRemote) +} diff --git a/packages/experimental/cua/src/client/locales.ts b/packages/experimental/cua/src/client/locales.ts new file mode 100644 index 0000000000..0702e20597 --- /dev/null +++ b/packages/experimental/cua/src/client/locales.ts @@ -0,0 +1,76 @@ +/** Product copy for native permission onboarding and application revocation. */ +export const NS = 'cuaPermissions' +/** English permission-page dictionary. */ +export const en = { + title: 'Authorize Computer Use', + applicationsTitle: 'Manage applications Computer Use can access', + setupTitle: 'Set up Computer Use before use', + setupBody: 'Computer Use is enabled. Before using it, switch to PTC mode and grant Accessibility and Screen Recording permissions. Open the plugin details to complete setup.', + setupQueryFailed: 'Computer Use is enabled, but system permissions could not be checked. Open the plugin details to see the error and retry.', + defaultTitle: 'Default mode for new sessions', + defaultBody: 'Computer Use is available only in PTC mode.', + defaultConfirm: 'Set to PTC', defaultSet: 'PTC is the default', + defaultUnavailable: 'Could not read the default mode. Check it in Settings.', + defaultReadOnly: 'Default-mode settings are read-only.', + defaultNotSaved: 'The default mode was not saved. Try again.', + defaultFailed: 'Could not change the default mode: {message}', + persistentTitle: 'PTC execution mode', + persistentBody: 'Keep variables and application objects across tool calls. Switching restarts the PTC runtime.', + persistentConfirm: 'Use persistent mode', persistentSet: 'Persistent mode enabled', + persistentUnavailable: 'Could not read the PTC execution mode. Check it in Settings.', + persistentReadOnly: 'PTC execution settings are read-only.', + persistentNotSaved: 'The PTC execution mode was not saved. Try again.', + persistentFailed: 'Could not change the PTC execution mode: {message}', + openSetup: 'Open settings', later: 'Later', close: 'Close', + accessibility: 'Accessibility', screenRecording: 'Screen Recording', + accessibilityUse: 'Read interface controls, click, and type.', screenRecordingUse: 'Capture the selected application window.', + accessibilityPermission: 'In System Settings, allow {application} to read interface controls, click, and type.', + screenRecordingPermission: 'In System Settings, allow {application} to capture application windows.', + permissionHint: 'Enable the application in System Settings. Follow the macOS prompt if it names another application.', + permissionHintUnknown: 'Enable these permissions for the application named in the macOS prompt.', + granted: 'Allowed', unknown: 'Unknown', request: 'Request permission', settings: 'Open System Settings', retry: 'Retry', + allowedApps: 'Always-allowed applications', + blockedApps: 'Applications not allowed', retryApproval: 'Allow asking again', + retryAllowed: 'The next application selection will ask again; access has not been granted', + noApps: 'No always-allowed or blocked applications.', + revoke: 'Revoke', revoked: 'Application access revoked', requested: 'Review the macOS prompt, or open System Settings if no prompt appears', opened: 'System Settings opened', + applicationsFailed: 'Could not load application access: {message}', iconsFailed: 'Could not load application icons: {message}', + failed: 'Could not update permissions: {message}', queryFailed: 'Could not check permissions: {message}', +} +/** Keys shared by all supported locale dictionaries. */ +export type CuaPermissionLocaleKey = keyof typeof en +/** Chinese permission-page dictionary. */ +export const zh: Record = { + title: '给 Computer Use 授权', applicationsTitle: '管理 Computer Use 可访问的应用', setupTitle: '使用 Computer Use 前需要设置', + setupBody: 'Computer Use 插件已开启。使用前请切换到 PTC 模式,并授予辅助功能和屏幕录制权限。请前往插件详情页完成设置。', + setupQueryFailed: 'Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', + defaultTitle: '新会话默认模式', + defaultBody: 'Computer Use 仅在 PTC 模式中可用。', + defaultConfirm: '设为 PTC', defaultSet: '已设为 PTC', + defaultUnavailable: '无法读取默认模式,请在设置中检查', + defaultReadOnly: '默认模式设置为只读', + defaultNotSaved: '默认模式未保存,请重试', + defaultFailed: '无法切换默认模式:{message}', + persistentTitle: 'PTC 执行方式', + persistentBody: '跨工具调用保留变量和应用对象。切换会重启 PTC 执行器。', + persistentConfirm: '设为持久化', persistentSet: '已设为持久化', + persistentUnavailable: '无法读取 PTC 执行方式,请在设置中检查', + persistentReadOnly: 'PTC 执行方式设置为只读', + persistentNotSaved: 'PTC 执行方式未保存,请重试', + persistentFailed: '无法切换 PTC 执行方式:{message}', + openSetup: '前往设置', later: '稍后', close: '关闭', + accessibility: '辅助功能', screenRecording: '屏幕录制', + accessibilityUse: '读取界面控件、点击和输入文字', screenRecordingUse: '截取所选应用的窗口', + accessibilityPermission: '在系统设置中允许 {application} 读取界面、点击和输入。', + screenRecordingPermission: '在系统设置中允许 {application} 截取应用窗口。', + permissionHint: '在系统设置中开启对应应用;若 macOS 提示其他应用,以系统提示为准', + permissionHintUnknown: '在系统设置中,为 macOS 提示的应用开启对应权限', + granted: '已允许', unknown: '未确认', request: '申请权限', settings: '打开系统设置', retry: '重试', + allowedApps: '始终允许的应用', + blockedApps: '不允许访问的应用', retryApproval: '允许再次询问', + retryAllowed: '下次选择此应用时将重新询问,当前尚未授权', + noApps: '暂无始终允许或不允许访问的应用。', + revoke: '撤销', revoked: '已撤销应用访问权限', requested: '请查看 macOS 提示;若未出现,请打开系统设置', opened: '已打开系统设置', + applicationsFailed: '无法读取应用授权:{message}', iconsFailed: '无法读取应用图标:{message}', + failed: '无法更新权限:{message}', queryFailed: '无法检测权限:{message}', +} diff --git a/packages/experimental/cua/src/client/mount.ts b/packages/experimental/cua/src/client/mount.ts new file mode 100644 index 0000000000..bc43e9bd8b --- /dev/null +++ b/packages/experimental/cua/src/client/mount.ts @@ -0,0 +1,69 @@ +/** Optional Computer Use controls mounted through existing plugin-page and overlay slots. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-cua/remote' +import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { PermissionController } from './controller.ts' +import { PermissionPanel, PermissionSetup, PermissionToast } from './PermissionPanel.tsx' +import { en, NS, zh, type CuaPermissionLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Native permission controls and application consent. */ + cuaPermissions: CuaPermissionLocaleKey + } +} + +/** Existing browser registries needed by the optional permission UI. */ +export const inject = ['remote', 'slots', 'locale', 'configForms'] + +/** + * Mount the optional permission Remote and its browser controls. + * @param ctx Browser plugin context. + * @param contribution Generated permission Remote definitions. + * @returns Cleanup for the optional Remote and UI registrations. + */ +export async function mountCuaPermissions(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { + const transport = ctx.plugin(async (owner) => { await owner.remote.$mount(contribution) }) + const ui = ctx.inject(['remote.cuaAccess', 'slots', 'locale'], (ctx) => { + ctx.effect(() => ctx.locale.register(NS, { en, zh })) + const controller = new PermissionController(ctx.remote.cuaAccess) + const face = controller.face() + const preset = ctx.configForms.get<{ default: string; selectedDefault?: string }>('agent-preset-registry') + const settings = { + ...face, + hooks: { ...face.hooks, cuaDefaultPreset: preset }, + setPtcDefault: () => preset.set('selectedDefault', 'ptc'), + setPersistentPtc: async () => { + const result = await ctx.remote.cuaAccess.enablePersistentPtc(); if (!result.ok) throw result.error + face.refresh() + return true + }, + } + ctx.effect(() => () => controller.dispose()) + ctx.on('connection/reset', face.refresh) + ctx.effect(() => ctx.remote.$on('settings/document-updated', (namespace) => { + if (namespace === controller.state.getSnapshot().applications.value?.settingsNamespace) face.refresh() + })) + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', key: '@deepseek-ai/dsh-cua-bundle', locale: NS, inject: () => settings, + }, PermissionPanel)) + ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({ + name: 'plugins.bundle.activation', key: '@deepseek-ai/dsh-cua-bundle', locale: NS, inject: () => face, + }, PermissionSetup)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'cua-permission-outcome', locale: NS, inject: () => face, + }, PermissionToast)) + }) + const dispose = ctx.effect(function* () { + yield transport.dispose + yield ui.dispose + }, 'cua-permissions.mount') + try { await Promise.all([transport, ui]) } catch (error) { await dispose(); throw error } + return dispose +} diff --git a/packages/experimental/cua/src/css-modules.d.ts b/packages/experimental/cua/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/experimental/cua/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index edf3121a0a..1cec00f09a 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -13,22 +13,27 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { z } from 'zod' import { NativeClient, NativeError, type NativeClientConfig, type NativeChannelFactory } from './native-client.ts' -import { ApplicationAuthorizationResult, ApplicationAccess } from './application-access.ts' -import type { CuaApplicationGrant, CuaApplicationId } from './application-types.ts' +import { ApplicationAuthorizationResult, installCuaAccess } from './access.ts' +import type { CuaApplicationGrant, CuaApplicationId } from './access-types.ts' import type { CuaAppHandle, CuaOperation } from './types.ts' import { AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' import { CuaCoordinates } from './coordinates.ts' -import type {} from '@deepseek-ai/dsh-system-prompt' -import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import type { PromptSection } from '@deepseek-ai/dsh-system-prompt' +import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-computer-use' import type {} from '@deepseek-ai/dsh-ptc-runtime' import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/cordis-plugin-loader' +export { CuaAccess } from './access.ts' /** Native executable and per-operation resource bounds. */ export interface CuaConfig { + /** Register tools for Agents using this preset; defaults to ptc. False enables global registration. */ + agentPreset: string | false /** Signed applications explicitly approved for future tasks in this profile. */ allowedApps: Volatile & { /** Unbranded digest at the profile configuration boundary; admitted by the Config schema. */ @@ -74,6 +79,7 @@ export interface CuaConfig { /** Validated host configuration; no driver is downloaded at runtime. */ export const CuaConfig = Schema.object({ + agentPreset: Schema.union([Schema.string().min(1), Schema.const(false)]).default('ptc'), allowedApps: Schema.array(Schema.object({ id: Schema.string().pattern(/^[a-f0-9]{64}$/).required(), name: Schema.string().required(), executablePath: Schema.string().min(1).required(), @@ -161,7 +167,9 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) } const entryId = ctx.fiber.entry?.options.id ctx.inject(['settings'], scope => scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber))) - const access = new ApplicationAccess(ctx, { + const access = installCuaAccess(ctx, { + settingsNamespace: entryId ?? null, + native, createChannel: backend.createChannel, grants: () => config.allowedApps.get().map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), get save() { const settings = ctx.get('settings') @@ -280,7 +288,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) if (method === 'listWindows') execution.coordinates.observe(parsedParams.appHandle as CuaAppHandle, WindowsResult.parse(result)) return result } - yield ctx.tools.register({ + const tool: ToolDefinition = { name: 'cua', description: backend.description, parameters: { ...z.record(z.string(), z.json()).parse(z.toJSONSchema(Request)), type: 'object' }, output: { schema: {}, render: renderResult }, @@ -323,14 +331,50 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) pending.delete(operation) } }, - }) - yield ctx.systemPrompt.section({ + } + const prompt: PromptSection = { name: 'computer-use:dsh-cua', order: ctx.systemPrompt.getSectionOrder('TOOL_COMPUTER_USE'), text: () => ctx.get('ptcRuntime')?.programFormat !== 'repl' ? guidance : `${guidance} Persistent PTC initialization: var { connectCua } = await import(${JSON.stringify(new URL('../lib/bridge.js', import.meta.url).href)}); var cua = connectCua(() => tools.cua, text => console.log(text));`, - }) + } + const register = (scope: Context): void => { + scope.tools.register(tool) + scope.systemPrompt.section(prompt) + } + if (config.agentPreset === false) register(ctx) + else { + const consumer = ctx.inject(['agentPresets'], async (presetCtx) => { + const scopes = new Map() + const sync = async (agent: Agent): Promise => { + const previous = scopes.get(agent) + if (presetCtx.agentPresets.composedPreset(agent.ctx) !== config.agentPreset) { + scopes.delete(agent) + await previous?.dispose() + return + } + if (previous !== undefined) return + const scope = createScope(agent.ctx, agent) + scopes.set(agent, scope) + scope.ctx.effect(() => () => { + if (scopes.get(agent) === scope) scopes.delete(agent) + }, 'cua.agent-tools') + register(scope.ctx) + } + presetCtx.effect(() => async () => { await Promise.all([...scopes.values()].map(scope => scope.dispose())) }, 'cua.agent-tools') + presetCtx.on('agent/created', async ({ agent }) => { await sync(agent) }) + presetCtx.on('agent-preset/selected', (id) => { + const agent = presetCtx.agents.get(id) + if (agent !== undefined) void sync(agent).catch((error: unknown) => { presetCtx.logger.warn(`CUA tool scope update failed: ${String(error)}`) }) + }) + await Promise.all(presetCtx.agents.list().map(sync)) + }) + yield () => consumer.dispose() + } }, 'dsh-cua.runtime') } + +/** Host marker for the optional Client contribution; platform providers install native execution separately. */ +export function apply(): void {} diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 0af1ee0fda..3e1d9f6450 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -81,8 +81,13 @@ export const NativeResourceParameters = { }).strict(), findApps: z.object({ maxAppMatches: z.number().int().min(1).max(1000) }).strict(), } -/** Executor-only continuation after application consent; absent from the model Request union. */ -export const NativeOnlyOperations = [operation('launchApp', app)] as const +/** Host-only launch continuation and user-facing system permission controls; absent from model arguments. */ +export const NativeOnlyOperations = [ + operation('launchApp', app), operation('permissionStatus', {}), + operation('applicationIcons', { paths: z.array(z.string().min(1)) }), + operation('requestPermission', { permission: z.enum(['accessibility', 'screenRecording']) }), + operation('openPermissionSettings', { permission: z.enum(['accessibility', 'screenRecording']) }), +] as const /** Process identity returned only after a successful app selection. */ export const AppResult = z.object({ @@ -127,3 +132,6 @@ export const ActionResult = z.object({ dispatched: z.literal(true), verified: z. export const HoverResult = ActionResult.extend({ hoverRoute: z.enum(['accessibility', 'system-pointer']), systemPointerMoved: z.boolean() }) /** Background restoration dispatch and sampled foreground changes; null means the observation was unavailable. */ export const ShowResult = ActionResult.extend({ windowId: id.nullable(), foregroundChanged: z.boolean().nullable() }) + +/** Bounded native PNG thumbnail for human presentation, excluded from model results and saved grants. */ +export const ApplicationIcon = z.string().max(65536).regex(/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/) diff --git a/packages/experimental/cua/src/remote-types.ts b/packages/experimental/cua/src/remote-types.ts new file mode 100644 index 0000000000..6c16dde03d --- /dev/null +++ b/packages/experimental/cua/src/remote-types.ts @@ -0,0 +1,3 @@ +/** Named Remote boundary types required by Typert's generated Client declarations. */ +export type { CuaApplicationId } from './application-types.ts' +export type { CuaApplicationState, CuaSystemPermission, CuaSystemPermissionState } from './access-types.ts' diff --git a/packages/experimental/cua/tests/access.spec.ts b/packages/experimental/cua/tests/access.spec.ts new file mode 100644 index 0000000000..8cffbcc4e9 --- /dev/null +++ b/packages/experimental/cua/tests/access.spec.ts @@ -0,0 +1,419 @@ +/** Application decisions are checked at dispatch and revocation cancels owned work. */ +import { setImmediate } from 'node:timers/promises' +import { Context } from '@deepseek-ai/cordis' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import UserQuestions, { type AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions' +import { expect, it, onTestFinished, vi } from 'vitest' +import { ApplicationAuthorizationResult, installCuaAccess } from '../src/access.ts' +import type { CuaApplicationGrant } from '../src/access-types.ts' +import type { CuaAppHandle } from '../src/types.ts' +import { NativeClient } from '../src/native-client.ts' + +const native = vi.hoisted(() => ({ instances: [] as Array<{ + disposed: boolean + request: ReturnType + dispose: ReturnType Promise>> +}> })) + +vi.mock('../src/native-client.ts', async original => ({ + ...await original(), + NativeClient: class { + disposed = false + request = vi.fn(async (method: string) => method === 'openPermissionSettings' ? { opened: true } + : method === 'applicationIcons' ? {} : { accessibility: false, screenRecording: false, helper: { pid: 9 }, parent: { pid: 8 } }) + invalidate() { this.disposed = true } + dispose = vi.fn(async () => { this.disposed = true }) + constructor() { native.instances.push(this) } + }, +})) + +const unusedConnector = () => { throw new Error('Mocked native client does not connect') } + +const nativeConfig = { helperPath: '/fixture', cwd: '/', timeoutMs: 1000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 100, + maxRequestBytes: 4096, maxResponseBytes: 4096, graceMs: 100, textEventIntervalMs: 1, maxClickHoldMs: 5000 } +const identity = ApplicationAuthorizationResult.parse({ id: 'a'.repeat(64), name: 'Test application', executablePath: '/Applications/Fixture.app/Contents/MacOS/Fixture', persistable: true }) +const handle = 'fixture-app' as CuaAppHandle +const signal = new AbortController().signal + +async function setup(choice = 'Allow for this session', options: { writable?: boolean; questions?: boolean; initial?: readonly CuaApplicationGrant[] } = {}) { + native.instances.length = 0 + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + if (options.questions !== false) await ctx.plugin(UserQuestions) + const owner = await ctx.agents.create({ sessionId: SessionId('cua-access-test') }) + const answer = vi.fn<() => Promise>(async () => ({ answers: [{ id: 'cua-app-access', selected: [choice] }] })) + ctx.on('user-questions/request', answer) + let grants: readonly CuaApplicationGrant[] = options.initial ?? [] + const save = vi.fn(async (next: readonly CuaApplicationGrant[]) => { grants = next }) + let writable = options.writable !== false + const access = installCuaAccess(ctx, { native: nativeConfig, createChannel: unusedConnector, settingsNamespace: 'cua', grants: () => grants, get save() { return writable ? save : undefined } }) + const client = new NativeClient(ctx, nativeConfig, unusedConnector) + return { ctx, owner, agent: owner.agent, answer, save, access, client, grants: () => grants, + setWritable(value: boolean) { writable = value }, + } +} + +it('requires another user decision after the permission page permits asking again', async () => { + const f = await setup('Deny') + await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED') + expect(f.ctx.cuaAccess.applications().blockedApps).toHaveLength(1) + await f.ctx.cuaAccess.retryApproval(identity.id) + expect(f.ctx.cuaAccess.applications().blockedApps).toEqual([]) + await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REQUIRED') + f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) + const retryClient = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await f.access.select(f.agent, retryClient, handle, identity, signal) + expect(f.answer).toHaveBeenCalledTimes(2) + await f.ctx.cuaAccess.revoke(identity.id) + await f.ctx.cuaAccess.retryApproval(identity.id) + await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REQUIRED') +}) + +it('persists a signed identity and revokes it before further dispatch', async () => { + const f = await setup('Always allow') + await f.access.select(f.agent, f.client, handle, identity, signal) + expect(f.grants()).toEqual([{ id: identity.id, name: identity.name, executablePath: identity.executablePath }]) + await f.ctx.cuaAccess.revoke(identity.id) + expect(f.grants()).toEqual([]) + await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REVOKED') + await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED') +}) + +it('joins an in-flight native operation when application access is revoked', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + const entered = Promise.withResolvers() + const pending = f.access.run(f.client, handle, signal, async (operationSignal) => { + const done = Promise.withResolvers() + operationSignal.addEventListener('abort', () => { done.reject(new Error('native cancelled')) }, { once: true }) + entered.resolve(undefined) + return done.promise + }) + const rejected = expect(pending).rejects.toThrow('APP_ACCESS_DENIED') + await entered.promise + await f.ctx.cuaAccess.revoke(identity.id) + await rejected + await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REVOKED') +}) + +it.each(['revoke', 'dispose'] as const)('waits for native cancellation to finish during %s', async (action) => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + const entered = Promise.withResolvers(), cancelled = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + const pending = f.access.run(f.client, handle, signal, async (operationSignal) => { + operationSignal.addEventListener('abort', () => { cancelled.resolve(undefined) }, { once: true }) + entered.resolve(operationSignal) + await release.promise + }) + const rejected = expect(pending).rejects.toThrow() + const operationSignal = await entered.promise + let finished = false + const closing = (action === 'revoke' ? f.ctx.cuaAccess.revoke(identity.id) : f.access.dispose()) + .then(() => { finished = true }) + try { + await cancelled.promise + await setImmediate() + expect(operationSignal.aborted).toBe(true) + expect(finished).toBe(false) + release.resolve(undefined) + await closing + await rejected + expect(finished).toBe(true) + } finally { + release.resolve(undefined) + await Promise.all([closing, rejected]) + } +}) + +it('fails closed without a human answerer and never offers persistence without its owner', async () => { + const f = await setup('Always allow', { writable: false, questions: false }) + await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_APPROVAL_UNAVAILABLE') + await f.ctx.plugin(UserQuestions) + await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED') + expect(f.save).not.toHaveBeenCalled() + expect(f.ctx.cuaAccess.applications().persistentAvailable).toBe(false) +}) + +it('projects task decisions, observes live config removal, and forgets a disposed task', async () => { + const f = await setup('Always allow') + await f.access.select(f.agent, f.client, handle, identity, signal) + expect(f.ctx.cuaAccess.applications().allowedApps).toHaveLength(1) + await f.save([]) + f.ctx.emit('loader/volatile-update', [['allowedApps']]) + await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REVOKED') + await f.owner.dispose() + f.access.forget(f.agent) +}) + +it('refuses to promise revocation of a saved decision without persistence', async () => { + const { persistable: _persistable, ...grant } = identity + const f = await setup('Deny', { initial: [grant], writable: false }) + await expect(f.ctx.cuaAccess.revoke(identity.id)).rejects.toThrow('cannot be saved') + expect(f.grants()).toEqual([grant]) +}) + +it('checks native permissions without converting an OS request into a grant', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + const before = f.ctx.cuaAccess.applications() + expect(before).toEqual({ settingsNamespace: 'cua', allowedApps: [], blockedApps: [], persistentAvailable: true }) + expect(await f.ctx.cuaAccess.systemStatus()).toMatchObject({ accessibility: false, screenRecording: false }) + expect(await f.ctx.cuaAccess.requestPermission('accessibility')).toMatchObject({ accessibility: false }) + await f.ctx.cuaAccess.openSettings('screenRecording') + const transport = native.instances[1]! + expect(transport.request).toHaveBeenCalledWith('openPermissionSettings', { permission: 'screenRecording' }, expect.any(AbortSignal)) + transport.request.mockRejectedValueOnce(new Error('transport ended')) + await expect(f.ctx.cuaAccess.systemStatus()).rejects.toThrow('transport ended') + transport.disposed = true + expect((await f.ctx.cuaAccess.systemStatus()).screenRecording).toBe(false) + expect(transport.dispose).toHaveBeenCalledOnce() + await f.access.dispose() + await expect(f.ctx.cuaAccess.systemStatus()).rejects.toThrow() + await expect(f.ctx.cuaAccess.revoke(identity.id)).rejects.toThrow() +}) + +it('does not open a replacement control connection after provider disposal begins', async () => { + const f = await setup() + await f.ctx.cuaAccess.systemStatus() + const control = native.instances.at(-1)! + control.disposed = true + const released = Promise.withResolvers(), entered = Promise.withResolvers() + onTestFinished(() => { released.resolve(undefined) }) + control.dispose.mockImplementationOnce(async () => { entered.resolve(undefined); await released.promise }) + const pending = f.ctx.cuaAccess.systemStatus() + const rejected = expect(pending).rejects.toThrow() + await entered.promise + const count = native.instances.length + const closing = f.access.dispose() + released.resolve(undefined) + await Promise.all([closing, rejected]) + expect(native.instances).toHaveLength(count) +}) + + +it('isolates other tasks and applications while revoking and forgetting one decision', async () => { + const f = await setup() + const owner = await f.ctx.agents.create({ sessionId: SessionId('another-cua-task') }) + const otherClient = new NativeClient(f.ctx, nativeConfig, unusedConnector) + const other = { ...identity, id: 'b'.repeat(64) as typeof identity.id } + const otherHandle = 'other-app' as CuaAppHandle + await f.access.select(f.agent, f.client, handle, identity, signal) + await f.access.select(f.agent, f.client, otherHandle, other, signal) + await f.access.select(owner.agent, otherClient, otherHandle, other, signal) + f.ctx.emit('loader/volatile-update', [['allowedApps']]) + await f.ctx.cuaAccess.retryApproval(identity.id) + const started: PromiseWithResolvers = Promise.withResolvers() + const release: PromiseWithResolvers = Promise.withResolvers() + onTestFinished(() => { release.resolve() }) + const continuing = f.access.run(otherClient, otherHandle, signal, async (operationSignal) => { + started.resolve() + await release.promise + return !operationSignal.aborted + }) + await started.promise + await f.ctx.cuaAccess.revoke(identity.id) + release.resolve() + expect(await continuing).toBe(true) + f.access.forget(f.agent) + await expect(f.access.run(otherClient, otherHandle, signal, async () => 'still allowed')).resolves.toBe('still allowed') + await owner.dispose() +}) + +it('removes a grant whose save is already pending when another task revokes the application', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + const owner = await f.ctx.agents.create({ sessionId: SessionId('cua-concurrent-save') }) + const client = new NativeClient(f.ctx, nativeConfig, unusedConnector) + const entered: PromiseWithResolvers = Promise.withResolvers() + const release: PromiseWithResolvers = Promise.withResolvers() + onTestFinished(() => { release.resolve() }) + const save = f.save.getMockImplementation()! + f.save.mockImplementationOnce(async (grants) => { entered.resolve(); await release.promise; await save(grants) }) + f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] }) + const selection = f.access.select(owner.agent, client, handle, identity, signal) + const settledSelection = Promise.allSettled([selection]) + await entered.promise + const revoked = f.ctx.cuaAccess.revoke(identity.id) + release.resolve() + await revoked + await settledSelection + expect(f.grants()).toEqual([]) + expect(f.save).toHaveBeenCalledTimes(2) + expect(client.disposed).toBe(true) + expect(f.client.disposed).toBe(true) + const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await expect(f.access.select(owner.agent, replacement, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED') +}) + +it('keeps revoked helper handles invalid after the application is approved again', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + await f.ctx.cuaAccess.revoke(identity.id) + expect(f.client.disposed).toBe(true) + await f.ctx.cuaAccess.retryApproval(identity.id) + await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('NATIVE_CLOSED') + const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) + const replacementHandle = 'replacement-app' as CuaAppHandle + await f.access.select(f.agent, replacement, replacementHandle, identity, signal) + const oldDispatch = vi.fn(async () => 'stale window') + await expect(f.access.run(f.client, handle, signal, oldDispatch)).rejects.toThrow('APP_ACCESS_REQUIRED') + expect(oldDispatch).not.toHaveBeenCalled() + await expect(f.access.run(replacement, replacementHandle, signal, async () => 'fresh window')).resolves.toBe('fresh window') +}) + +it('projects restored persistent grants without showing the same application as blocked', async () => { + const f = await setup('Deny') + await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED') + const { persistable: _persistable, ...grant } = identity + await f.save([grant]) + expect(f.ctx.cuaAccess.applications().blockedApps).toEqual([]) + const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await f.access.select(f.agent, replacement, handle, identity, signal) + expect(f.ctx.cuaAccess.applications().allowedApps).toEqual([grant]) + expect(f.answer).toHaveBeenCalledOnce() +}) + +it('keeps access revoked when stopping the helper reports a cleanup failure', async () => { + const f = await setup('Always allow') + await f.access.select(f.agent, f.client, handle, identity, signal) + vi.spyOn(f.client, 'dispose').mockRejectedValueOnce(new Error('process range did not exit')) + await expect(f.ctx.cuaAccess.revoke(identity.id)).rejects.toThrow('helper cleanup failed') + expect(f.grants()).toEqual([]) + expect(f.client.disposed).toBe(true) + const dispatch = vi.fn(async () => 'unexpected input') + await expect(f.access.run(f.client, handle, signal, dispatch)).rejects.toThrow('APP_ACCESS_REVOKED') + expect(dispatch).not.toHaveBeenCalled() +}) + +it('rejects an answer for a helper that exits while the human question is pending', async () => { + const f = await setup() + const answer = Promise.withResolvers() + const entered: PromiseWithResolvers = Promise.withResolvers() + onTestFinished(() => { answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Deny'] }] }) }) + f.answer.mockImplementationOnce(() => { entered.resolve(); return answer.promise }) + const selected = f.access.select(f.agent, f.client, handle, identity, signal) + const rejected = expect(selected).rejects.toThrow('NATIVE_CLOSED') + await entered.promise + f.client.invalidate() + answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) + await rejected + const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await f.access.select(f.agent, replacement, handle, identity, signal) + expect(f.answer).toHaveBeenCalledTimes(2) +}) + +it('keeps other session grants after revocation while requiring fresh native handles', async () => { + const f = await setup() + const other = { ...identity, id: 'b'.repeat(64) as typeof identity.id } + const otherHandle = 'other-application' as CuaAppHandle + await f.access.select(f.agent, f.client, handle, identity, signal) + await f.access.select(f.agent, f.client, otherHandle, other, signal) + await f.ctx.cuaAccess.revoke(identity.id) + await expect(f.access.run(f.client, otherHandle, signal, async () => 'old handle')).rejects.toThrow('NATIVE_CLOSED') + const state = f.ctx.cuaAccess.applications() + expect(state.blockedApps.map(app => app.id)).toEqual([identity.id]) + const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await f.access.select(f.agent, replacement, otherHandle, other, signal) + await expect(f.access.run(replacement, otherHandle, signal, async () => 'new handle')).resolves.toBe('new handle') + expect(f.answer).toHaveBeenCalledTimes(2) +}) + +it('does not let a stale revoked task invalidate another task after a saved grant is restored', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + await f.ctx.cuaAccess.revoke(identity.id) + const { persistable: _persistable, ...grant } = identity + await f.save([grant]) + const owner = await f.ctx.agents.create({ sessionId: SessionId('cua-restored-grant') }) + const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await f.access.select(owner.agent, replacement, handle, identity, signal) + await expect(f.access.run(f.client, handle, signal, async () => 'stale')).rejects.toThrow('APP_ACCESS_REVOKED') + f.ctx.emit('loader/volatile-update', [['allowedApps']]) + expect(replacement.disposed).toBe(false) + await expect(f.access.run(replacement, handle, signal, async () => 'restored')).resolves.toBe('restored') +}) + +it('reads application decisions without native work and retrieves icons separately', async () => { + const f = await setup('Always allow') + await f.access.select(f.agent, f.client, handle, identity, signal) + expect(f.ctx.cuaAccess.applications().allowedApps).toEqual(f.grants()) + expect(native.instances).toHaveLength(1) + await f.ctx.cuaAccess.systemStatus() + const control = native.instances.at(-1)! + const pending = Promise.withResolvers>() + onTestFinished(() => { pending.resolve({}) }) + control.request.mockReturnValueOnce(pending.promise) + const icons = f.ctx.cuaAccess.applicationIcons() + expect(f.ctx.cuaAccess.applications().allowedApps).toEqual(f.grants()) + pending.resolve({ [identity.executablePath]: 'data:image/png;base64,aWNvbg==' }) + expect(await icons).toEqual({ [identity.executablePath]: 'data:image/png;base64,aWNvbg==' }) + expect(f.grants()[0]).not.toHaveProperty('icon') +}) + +it('skips native icon work for an empty application list and propagates icon failures independently', async () => { + const f = await setup() + expect(await f.ctx.cuaAccess.applicationIcons()).toEqual({}) + expect(native.instances).toHaveLength(1) + await f.access.select(f.agent, f.client, handle, identity, signal) + expect(await f.ctx.cuaAccess.applicationIcons()).toEqual({}) + expect(native.instances).toHaveLength(1) + await f.save([{ id: identity.id, name: identity.name, executablePath: identity.executablePath }]) + await f.ctx.cuaAccess.systemStatus() + native.instances.at(-1)!.request.mockRejectedValueOnce(new Error('icons unavailable')) + await expect(f.ctx.cuaAccess.applicationIcons()).rejects.toThrow('icons unavailable') + expect(f.ctx.cuaAccess.applications().allowedApps).toHaveLength(1) + expect((await f.ctx.cuaAccess.systemStatus()).accessibility).toBe(false) +}) + +it('preserves captured launch metadata separately from the current helper parent', async () => { + const f = await setup() + await f.ctx.cuaAccess.systemStatus() + const control = native.instances.at(-1)! + const launchApplication = { name: 'Visual Studio Code', path: '/Applications/Visual Studio Code.app', icon: 'data:image/png;base64,aWNvbg==' } + control.request.mockResolvedValueOnce({ accessibility: false, screenRecording: false, + helper: { pid: 9 }, parent: { pid: 1, path: '/sbin/launchd' }, launchApplication }) + expect(await f.ctx.cuaAccess.systemStatus()).toMatchObject({ parent: { pid: 1 }, launchApplication }) +}) + + +it('omits session-only grants from the permission page and retains their access after another session saves a grant', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + const other = { ...identity, id: 'b'.repeat(64) as typeof identity.id, name: 'Another application', executablePath: '/Applications/Other.app' } + await f.access.select(f.agent, f.client, 'other-application' as CuaAppHandle, other, signal) + expect(f.ctx.cuaAccess.applications()).toEqual({ settingsNamespace: 'cua', allowedApps: [], blockedApps: [], persistentAvailable: true }) + const owner = await f.ctx.agents.create({ sessionId: SessionId('cua-always-allow') }) + const client = new NativeClient(f.ctx, nativeConfig, unusedConnector) + f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] }) + await f.access.select(owner.agent, client, handle, identity, signal) + const state = f.ctx.cuaAccess.applications() + expect(state.allowedApps.map(app => app.id)).toEqual([identity.id]) + expect(state.blockedApps).toEqual([]) + await expect(f.access.run(f.client, handle, signal, async () => 'still allowed')).resolves.toBe('still allowed') +}) + +it('configures persistent PTC through the existing profile editor only on explicit request', async () => { + const f = await setup() + expect(f.ctx.cuaAccess.ptcConfiguration()).toEqual({ persistent: false, writable: false }) + await expect(f.ctx.cuaAccess.enablePersistentPtc()).rejects.toThrow('no editable profile entry') + const entry = { options: { id: 'ptc-runtime' } } + const runtime = { programFormat: 'function-body' } + const edit = vi.fn(async (received: object, change: (current: Record) => Record) => { + expect(received).toBe(entry) + expect(change({ timeoutMs: 4200 })).toEqual({ timeoutMs: 4200, executionMode: 'persistent-in-memory' }) + runtime.programFormat = 'repl' + }) + f.ctx.provide('ptcRuntime', runtime) + f.ctx.provide('configEditor', { entries: () => [entry], edit }) + expect(f.ctx.cuaAccess.ptcConfiguration()).toEqual({ persistent: false, writable: true }) + expect(edit).not.toHaveBeenCalled() + await f.ctx.cuaAccess.enablePersistentPtc() + expect(f.ctx.cuaAccess.ptcConfiguration()).toEqual({ persistent: true, writable: true }) + await f.ctx.cuaAccess.enablePersistentPtc() + expect(edit).toHaveBeenCalledOnce() +}) diff --git a/packages/experimental/cua/tests/agent-scopes.spec.ts b/packages/experimental/cua/tests/agent-scopes.spec.ts new file mode 100644 index 0000000000..fd1a349130 --- /dev/null +++ b/packages/experimental/cua/tests/agent-scopes.spec.ts @@ -0,0 +1,106 @@ +/** CUA tool and prompt ownership follows live Agent presets and provider disposal. */ +import { resolve } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import AgentPresets from '@deepseek-ai/dsh-agent-preset-registry' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use' +import Subprocess from '@deepseek-ai/dsh-subprocess' +import { SessionId } from '@deepseek-ai/dsh-session' +import { expect, it, onTestFinished, vi } from 'vitest' +import { CuaConfig, installCua } from '../src/index.ts' + +const provider = { + name: 'test-cua-scopes', + inject: ['computerUse', 'subprocess', 'tools', 'systemPrompt', 'agents'], + Config: CuaConfig, + apply(ctx: Context, config: CuaConfig) { + installCua(ctx, config, { + helperPath: resolve('unused-cua-helper'), + createChannel() { throw new Error('Scope tests must not start a native helper') }, + description: 'Fixture computer interaction', guidance: 'Fixture CUA guidance', + }) + }, +} + +async function setup(presetIds = ['standard', 'ptc']) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.baseUrl = import.meta.url + await ctx.plugin(Loader) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + await ctx.plugin(ComputerUseRegistry) + await ctx.plugin(Subprocess, []) + await ctx.plugin(AgentPresets, { default: 'standard' }) + for (const id of presetIds) { + await ctx.plugin({ inject: ['agentPresets'], async* apply(owner: Context) { + yield await owner.agentPresets.register({ id, plugins: [] }) + } }) + } + const create = (id: string, preset: string) => ctx.agents.create({ + sessionId: SessionId(id), setup: async (owner) => { await ctx.agentPresets.mount(owner, preset) }, + }) + return { ctx, create } +} + +it('adds and removes CUA for existing and new PTC Agents without changing other presets', async () => { + const { ctx, create } = await setup() + const existing = await create('existing', 'ptc') + const standard = await create('standard', 'standard') + expect(ctx.tools.get('cua', existing.agent)).toBeUndefined() + const mounted = await ctx.plugin(provider, {}) + await expect.poll(() => ctx.tools.get('cua', existing.agent)).toBeDefined() + expect(ctx.tools.get('cua', standard.agent)).toBeUndefined() + expect((await ctx.systemPrompt.assemble({ scope: existing.agent })).sections) + .toContainEqual(expect.objectContaining({ name: 'computer-use:dsh-cua', text: 'Fixture CUA guidance\n\nMaximum click holdMs: 5000 ms.' })) + await ctx.agentPresets.select(existing.agent, 'ptc') + expect(ctx.tools.schemas(existing.agent).filter(tool => tool.name === 'cua')).toHaveLength(1) + const fresh = await create('fresh', 'ptc') + expect(ctx.tools.get('cua', fresh.agent)).toBeDefined() + await ctx.agentPresets.select(existing.agent, 'standard') + await expect.poll(() => ctx.tools.get('cua', existing.agent)).toBeUndefined() + expect((await ctx.systemPrompt.assemble({ scope: existing.agent })).sections) + .not.toContainEqual(expect.objectContaining({ name: 'computer-use:dsh-cua' })) + await ctx.agentPresets.select(existing.agent, 'ptc') + await expect.poll(() => ctx.tools.get('cua', existing.agent)).toBeDefined() + await fresh.dispose() + expect(ctx.tools.get('cua', fresh.agent)).toBeUndefined() + await mounted.dispose() + expect(ctx.tools.get('cua', existing.agent)).toBeUndefined() + expect((await ctx.systemPrompt.assemble({ scope: existing.agent })).sections) + .not.toContainEqual(expect.objectContaining({ name: 'computer-use:dsh-cua' })) + const reloaded = await ctx.plugin(provider, {}) + await expect.poll(() => ctx.tools.get('cua', existing.agent)).toBeDefined() + expect(ctx.tools.get('cua', standard.agent)).toBeUndefined() + await reloaded.dispose() +}) + +it('ignores unloaded Session selections and reports a failed live scope update', async () => { + const { ctx, create } = await setup() + const handle = await create('active', 'ptc') + const mounted = await ctx.plugin(provider, {}) + await expect.poll(() => ctx.tools.get('cua', handle.agent)).toBeDefined() + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => {}) + const lookup = vi.spyOn(ctx.agentPresets, 'composedPreset') + onTestFinished(() => { lookup.mockRestore(); warn.mockRestore() }) + ctx.emit('agent-preset/selected', SessionId('unloaded'), 'ptc') + expect(lookup).not.toHaveBeenCalled() + lookup.mockImplementationOnce(() => { throw new Error('Preset lookup failed during reload') }) + ctx.emit('agent-preset/selected', handle.agent.id, 'ptc') + await expect.poll(() => warn.mock.calls).toEqual([['CUA tool scope update failed: Error: Preset lookup failed during reload']]) + expect(ctx.tools.get('cua', handle.agent)).toBeDefined() + await ctx.agentPresets.select(handle.agent, 'standard') + await expect.poll(() => ctx.tools.get('cua', handle.agent)).toBeUndefined() +}) + +it('accepts a matching preset registered after the CUA provider', async () => { + const { ctx, create } = await setup([]) + await ctx.plugin(provider, {}) + expect(ctx.tools.get('cua')).toBeUndefined() + await ctx.plugin({ inject: ['agentPresets'], async* apply(owner: Context) { + yield await owner.agentPresets.register({ id: 'ptc', plugins: [] }) + } }) + const handle = await create('late-preset', 'ptc') + expect(ctx.tools.get('cua', handle.agent)).toBeDefined() +}) diff --git a/packages/experimental/cua/tests/application-access.spec.ts b/packages/experimental/cua/tests/application-access.spec.ts index 38d5faa62d..74d2ddefbf 100644 --- a/packages/experimental/cua/tests/application-access.spec.ts +++ b/packages/experimental/cua/tests/application-access.spec.ts @@ -36,7 +36,7 @@ const identity = ApplicationAuthorizationResult.parse({ id: 'a'.repeat(64), name const handle = 'fixture-app' as CuaAppHandle const signal = new AbortController().signal -async function setup(choice = 'Allow for this task', options: { writable?: boolean; questions?: boolean; initial?: readonly CuaApplicationGrant[] } = {}) { +async function setup(choice = 'Allow for this session', options: { writable?: boolean; questions?: boolean; initial?: readonly CuaApplicationGrant[] } = {}) { native.instances.length = 0 const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -63,7 +63,7 @@ it('refuses raw handle dispatch until getApp obtains consent', async () => { expect(dispatch).not.toHaveBeenCalled() }) -it('retains task consent for the exact helper handle without saving it', async () => { +it('reuses session consent across application handles and connections without saving it', async () => { const f = await setup() await f.access.select(f.agent, f.client, handle, identity, signal) await expect(f.access.run(f.client, handle, signal, async () => 'read')).resolves.toBe('read') @@ -72,6 +72,11 @@ it('retains task consent for the exact helper handle without saving it', async ( expect(f.save).not.toHaveBeenCalled() const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) await expect(f.access.run(replacement, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REQUIRED') + await f.access.select(f.agent, replacement, 'fresh-app' as CuaAppHandle, identity, signal) + await f.access.select(f.agent, replacement, 'restarted-app' as CuaAppHandle, identity, signal) + expect(f.answer).toHaveBeenCalledOnce() + await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REQUIRED') + await expect(f.access.run(replacement, 'restarted-app' as CuaAppHandle, signal, async () => 'read')).resolves.toBe('read') }) it('does not repeatedly ask after an application is denied', async () => { @@ -120,10 +125,10 @@ it('reuses only saved signed identities when a task or helper is replaced', asyn it.each([ { answers: [] }, - { answers: [{ id: 'another-question', selected: ['Allow for this task'] }] }, - { answers: [{ id: 'cua-app-access', selected: ['Allow for this task', 'Deny'] }] }, + { answers: [{ id: 'another-question', selected: ['Allow for this session'] }] }, + { answers: [{ id: 'cua-app-access', selected: ['Allow for this session', 'Deny'] }] }, { answers: [{ id: 'cua-app-access', selected: [], custom: 'Always allow' }] }, - { answers: [{ id: 'cua-app-access', selected: ['Allow for this task'], custom: 'different scope' }] }, + { answers: [{ id: 'cua-app-access', selected: ['Allow for this session'], custom: 'different scope' }] }, ])('does not interpret an incomplete or free-text answer as consent: %j', async (answer) => { const f = await setup() f.answer.mockResolvedValueOnce(answer) @@ -199,8 +204,8 @@ it('reuses one accepted question for duplicate selections', async () => { f.answer.mockImplementationOnce(() => { entered.resolve(); return answer.promise }) const first = f.access.select(f.agent, f.client, handle, identity, signal) await entered.promise - const second = f.access.select(f.agent, f.client, handle, identity, signal) - answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] }) + const second = f.access.select(f.agent, f.client, 'another-handle' as CuaAppHandle, identity, signal) + answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) await Promise.all([first, second]) expect(f.answer).toHaveBeenCalledOnce() await expect(f.access.run(f.client, handle, signal, async () => 'allowed')).resolves.toBe('allowed') @@ -244,7 +249,7 @@ it('cancels native work when saved consent is removed from configuration', async entered.resolve(undefined) return done.promise }) - const rejected = expect(pending).rejects.toThrow('native cancelled') + const rejected = expect(pending).rejects.toThrow('APP_ACCESS_DENIED') await entered.promise await f.save([]) f.ctx.emit('loader/volatile-update', [['allowedApps']]) @@ -279,7 +284,7 @@ it('rejects an answer for a helper that exits while the human question is pendin const rejected = expect(selected).rejects.toThrow('NATIVE_CLOSED') await entered.promise f.client.invalidate() - answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] }) + answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) await rejected }) @@ -317,3 +322,57 @@ it('keeps an active task operation running when a different task denies the same await expect(pending).resolves.toBeNull() } finally { release.resolve(null); await pending } }) + + +it.each(['Allow for this session', 'Deny'])('retains %s when the same session gets a new agent', async (choice) => { + const f = await setup(choice) + const select = f.access.select(f.agent, f.client, handle, identity, signal) + if (choice === 'Deny') await expect(select).rejects.toThrow('APP_ACCESS_DENIED') + else await select + await f.owner.dispose() + f.access.forget(f.agent) + const owner = await f.ctx.agents.create({ sessionId: f.agent.id }) + const client = new NativeClient(f.ctx, nativeConfig, unusedConnector) + const resumed = f.access.select(owner.agent, client, handle, identity, signal) + if (choice === 'Deny') await expect(resumed).rejects.toThrow('APP_ACCESS_DENIED') + else { + await resumed + await expect(f.access.run(client, handle, signal, async () => 'resumed')).resolves.toBe('resumed') + } + expect(f.answer).toHaveBeenCalledOnce() +}) + +it('asks separately for another session and a changed application identity', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + const owner = await f.ctx.agents.create({ sessionId: SessionId('separate-session') }) + const client = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await f.access.select(owner.agent, client, handle, identity, signal) + expect(f.answer).toHaveBeenCalledTimes(2) + await f.access.select(f.agent, f.client, 'changed-app' as CuaAppHandle, + { ...identity, id: 'b'.repeat(64) as typeof identity.id }, signal) + expect(f.answer).toHaveBeenCalledTimes(3) +}) + +it('revokes session consent even after its agent and connection are released', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + f.access.forget(f.agent) + await f.access.revoke(identity.id) + const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await expect(f.access.select(f.agent, replacement, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED') + expect(f.answer).toHaveBeenCalledOnce() + f.access.retryApproval(identity.id) + await f.access.select(f.agent, replacement, handle, identity, signal) + expect(f.answer).toHaveBeenCalledTimes(2) +}) + +it('clears session consent when the provider is disabled and recreated', async () => { + const f = await setup() + await f.access.select(f.agent, f.client, handle, identity, signal) + await f.access.dispose() + const access = new ApplicationAccess(f.ctx, { grants: f.grants, save: f.save }) + const client = new NativeClient(f.ctx, nativeConfig, unusedConnector) + await access.select(f.agent, client, handle, identity, signal) + expect(f.answer).toHaveBeenCalledTimes(2) +}) diff --git a/packages/experimental/cua/tests/controller.client.spec.ts b/packages/experimental/cua/tests/controller.client.spec.ts new file mode 100644 index 0000000000..7a2f146ca2 --- /dev/null +++ b/packages/experimental/cua/tests/controller.client.spec.ts @@ -0,0 +1,281 @@ +/** Permission refreshes preserve observations and join user actions before withdrawal. */ +import { setImmediate } from 'node:timers/promises' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-cua/remote' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { expect, it, onTestFinished, vi } from 'vitest' +import type { CuaApplicationId, CuaApplicationState, CuaSystemPermissionState } from '../src/access-types.ts' +import { PermissionController } from '../src/client/controller.ts' + +const value: CuaSystemPermissionState = { accessibility: true, screenRecording: false, helper: { pid: 9 }, parent: { pid: 8 } } +const applications: CuaApplicationState = { + allowedApps: [], blockedApps: [], settingsNamespace: 'cua', persistentAvailable: true } +type CuaRemote = Context['remote']['cuaAccess'] +function fixture() { + const actions = { + ptcConfiguration: vi.fn(async () => ({ ok: true, value: { persistent: false, writable: true } })), + systemStatus: vi.fn(async () => ({ ok: true, value })), + applications: vi.fn(async () => ({ ok: true, value: applications })), + applicationIcons: vi.fn(async () => ({ ok: true, value: {} })), + requestPermission: vi.fn(async () => ({ ok: true, value })), + openSettings: vi.fn(async () => ({ ok: true, value: undefined })), + revoke: vi.fn(async () => ({ ok: true, value: undefined })), + retryApproval: vi.fn(async () => ({ ok: true, value: undefined })), + enablePersistentPtc: vi.fn(async () => ({ ok: true, value: undefined })), + } + const controller = new PermissionController(actions) + return { actions, controller, face: controller.face() } +} + +it('refreshes after a user request without treating the request itself as a grant', async () => { + const f = fixture() + f.face.request('screenRecording') + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.actions.requestPermission).toHaveBeenCalledWith('screenRecording') + expect(f.controller.state.getSnapshot().system.value?.screenRecording).toBe(false) + expect(f.controller.state.getSnapshot().toast?.kind).toBe('requested') + f.face.dismissToast() + expect(f.controller.state.getSnapshot().toast).toBeNull() + await f.controller.dispose() +}) + +it('retains the last observation while marking a failed refresh', async () => { + const f = fixture() + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().system.value).toEqual(value) }) + f.actions.systemStatus.mockRejectedValueOnce(new Error('connection closed')) + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().system.error).toBe('connection closed') }) + expect(f.controller.state.getSnapshot().system.value).toEqual(value) + await f.controller.dispose() +}) + +it.each([new Error('write refused'), 'write refused'])('reports settings and revocation outcomes through the shared toast state', async (error) => { + const f = fixture() + f.face.openSettings('accessibility') + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().toast?.kind).toBe('opened') }) + f.face.revoke('a'.repeat(64) as CuaApplicationId) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().toast?.kind).toBe('revoked') }) + f.face.retryApproval('a'.repeat(64) as CuaApplicationId) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().toast?.kind).toBe('retryAllowed') }) + f.actions.revoke.mockRejectedValueOnce(error) + f.face.revoke('a'.repeat(64) as CuaApplicationId) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().toast).toMatchObject({ kind: 'failed', message: 'write refused' }) }) + await f.controller.dispose() +}) + +it('joins a pending read without publishing after disposal or starting queued actions', async () => { + const f = fixture() + const pending = Promise.withResolvers() + const entered = Promise.withResolvers() + f.actions.systemStatus.mockImplementationOnce(async () => { + entered.resolve(undefined) + return { ok: true, value: await pending.promise } + }) + f.face.refresh() + await entered.promise + f.face.request('accessibility') + const before = f.controller.state.getSnapshot() + const closed = f.controller.dispose() + pending.resolve(value) + await closed + expect(f.controller.state.getSnapshot()).toBe(before) + expect(f.actions.requestPermission).not.toHaveBeenCalled() + f.face.refresh() + expect(f.actions.systemStatus).toHaveBeenCalledOnce() +}) + +it('revokes access while icons are pending, serializes writes, and joins reads on disposal', async () => { + const f = fixture(), icons = Promise.withResolvers>() + const action = Promise.withResolvers(), entered = Promise.withResolvers() + onTestFinished(async () => { icons.resolve({}); action.resolve(undefined); await f.controller.dispose() }) + f.actions.applicationIcons.mockImplementationOnce(async () => ({ ok: true, value: await icons.promise })) + f.actions.revoke.mockImplementationOnce(async () => { + entered.resolve(undefined) + await action.promise + return { ok: true, value: undefined } + }) + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().applications.busy).toBe(false) }) + expect(f.controller.state.getSnapshot().icons.busy).toBe(true) + f.face.revoke('a'.repeat(64) as CuaApplicationId) + await entered.promise + f.face.retryApproval('a'.repeat(64) as CuaApplicationId) + await setImmediate() + expect(f.actions.retryApproval).not.toHaveBeenCalled() + action.resolve(undefined) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.actions.retryApproval).toHaveBeenCalledOnce() + expect(f.controller.state.getSnapshot().toast?.kind).toBe('retryAllowed') + const before = f.controller.state.getSnapshot() + let disposed = false + const closed = f.controller.dispose().then(() => { disposed = true }) + await setImmediate() + expect(disposed).toBe(false) + icons.resolve({ '/old/app': 'old-icon' }) + await closed + expect(f.controller.state.getSnapshot()).toBe(before) +}) + +it('ignores stale toast completion after the permission UI is disposed', async () => { + const f = fixture() + f.face.request('accessibility') + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().toast?.kind).toBe('requested') }) + await f.controller.dispose() + const before = f.controller.state.getSnapshot() + f.face.dismissToast() + f.face.refresh() + expect(f.controller.state.getSnapshot()).toBe(before) +}) + +it.each(['success', 'failure'])('settles an in-flight action %s after disposal without publishing', async (outcome) => { + const f = fixture(), pending = Promise.withResolvers(), entered = Promise.withResolvers() + onTestFinished(async () => { pending.resolve(undefined); await f.controller.dispose() }) + f.actions.requestPermission.mockImplementationOnce(async () => { + entered.resolve(undefined) + await pending.promise + return { ok: true, value } + }) + f.face.request('accessibility') + await entered.promise + const before = f.controller.state.getSnapshot(), closed = f.controller.dispose() + if (outcome === 'failure') pending.reject(new Error('permission request ended after withdrawal')) + else pending.resolve(undefined) + await closed + expect(f.controller.state.getSnapshot()).toBe(before) + expect(f.actions.systemStatus).not.toHaveBeenCalled() +}) + +it('contains a late read rejection and supports errors without an Error instance', async () => { + const f = fixture() + f.actions.systemStatus.mockRejectedValueOnce('offline') + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().system.error).toBe('offline') }) + const pending = Promise.withResolvers(), entered = Promise.withResolvers() + f.actions.systemStatus.mockImplementationOnce(async () => { + entered.resolve(undefined) + return { ok: true, value: await pending.promise } + }) + f.face.refresh() + await entered.promise + const before = f.controller.state.getSnapshot(), closed = f.controller.dispose() + pending.reject(new Error('late disconnect')) + await closed + expect(f.controller.state.getSnapshot()).toBe(before) +}) + + +it.each(['success', 'failure'])('does not publish an older refresh %s while the activation refresh is queued', async (outcome) => { + const f = fixture() + const first = Promise.withResolvers(), firstEntered = Promise.withResolvers() + const latest = Promise.withResolvers(), latestEntered = Promise.withResolvers() + onTestFinished(async () => { first.resolve(value); latest.resolve(value); await f.controller.dispose() }) + f.actions.systemStatus.mockImplementationOnce(async () => { + firstEntered.resolve(undefined) + return { ok: true, value: await first.promise } + }) + f.actions.systemStatus.mockImplementationOnce(async () => { + latestEntered.resolve(undefined) + return { ok: true, value: await latest.promise } + }) + const observations: Array> = [] + const unsubscribe = f.controller.state.subscribe(() => { observations.push(f.controller.state.getSnapshot()) }) + onTestFinished(unsubscribe) + f.face.refresh() + await firstEntered.promise + f.face.refresh() + if (outcome === 'failure') first.reject(new Error('provider is reloading')) + else first.resolve({ ...value, screenRecording: true }) + await latestEntered.promise + expect(observations.every(state => state.system.error === null && state.system.value === null && state.busy)).toBe(true) + latest.resolve(value) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot()).toMatchObject({ busy: false, system: { error: null, value } }) }) + expect(observations.every(state => state.system.error === null)).toBe(true) +}) + +it('keeps a user-action failure visible when a refresh is queued behind it', async () => { + const f = fixture(), action = Promise.withResolvers(), entered = Promise.withResolvers() + onTestFinished(async () => { action.resolve(undefined); await f.controller.dispose() }) + f.actions.requestPermission.mockImplementationOnce(async () => { + entered.resolve(undefined) + await action.promise + return { ok: true, value } + }) + f.face.request('accessibility') + await entered.promise + f.face.refresh() + action.reject(new Error('permission request failed')) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot()).toMatchObject({ system: { value, error: null }, + toast: { kind: 'failed', message: 'permission request failed' } }) +}) + + +it('publishes each observation before the other queries finish', async () => { + const f = fixture() + const system = Promise.withResolvers() + const apps = Promise.withResolvers() + const icons = Promise.withResolvers>() + onTestFinished(async () => { system.resolve(value); apps.resolve(applications); icons.resolve({}); await f.controller.dispose() }) + f.actions.systemStatus.mockImplementationOnce(async () => ({ ok: true, value: await system.promise })) + f.actions.applications.mockImplementationOnce(async () => ({ ok: true, value: await apps.promise })) + f.actions.applicationIcons.mockImplementationOnce(async () => ({ ok: true, value: await icons.promise })) + f.face.refresh() + apps.resolve(applications) + await vi.waitFor(() => { + expect(f.controller.state.getSnapshot().applications).toEqual({ value: applications, error: null, busy: false }) + }) + expect(f.controller.state.getSnapshot().system).toMatchObject({ value: null, busy: true }) + system.resolve(value) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().system).toEqual({ value, error: null, busy: false }) }) + expect(f.controller.state.getSnapshot().icons).toMatchObject({ value: null, busy: true }) + icons.reject(new Error('icon unavailable')) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot()).toMatchObject({ + system: { value, error: null }, applications: { value: applications, error: null }, + icons: { value: null, error: 'icon unavailable', busy: false }, + }) +}) + +it('keeps application decisions available when system inspection fails', async () => { + const f = fixture() + f.actions.systemStatus.mockRejectedValueOnce(new Error('helper offline')) + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot()).toMatchObject({ + system: { value: null, error: 'helper offline' }, + applications: { value: applications, error: null, busy: false }, + }) + await f.controller.dispose() +}) + + +it.each([ + ['ptcConfiguration', 'runtime'], ['systemStatus', 'system'], + ['applications', 'applications'], ['applicationIcons', 'icons'], +] as const)('contains an unsuccessful %s response in its own query section', async (method, key) => { + const f = fixture() + onTestFinished(() => f.controller.dispose()) + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + const previous = f.controller.state.getSnapshot()[key].value + f.actions[method].mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'query refused', {}) }) + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot()[key]).toEqual({ value: previous, busy: false, error: 'query refused' }) + expect(f.controller.state.getSnapshot().toast).toBeNull() +}) + +it.each(['requestPermission', 'openSettings', 'revoke', 'retryApproval'] as const)( + 'reports an unsuccessful %s response instead of a success notice', async (method) => { + const f = fixture() + onTestFinished(() => f.controller.dispose()) + f.actions[method].mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'action refused', {}) }) + if (method === 'requestPermission') f.face.request('accessibility') + else if (method === 'openSettings') f.face.openSettings('accessibility') + else f.face[method]('a'.repeat(64) as CuaApplicationId) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot().toast).toMatchObject({ kind: 'failed', message: 'action refused' }) + expect(f.actions.systemStatus).toHaveBeenCalledOnce() + }, +) diff --git a/packages/experimental/cua/tests/export-types.spec.ts b/packages/experimental/cua/tests/export-types.spec.ts new file mode 100644 index 0000000000..399fd84355 --- /dev/null +++ b/packages/experimental/cua/tests/export-types.spec.ts @@ -0,0 +1,29 @@ +/** CUA's public types distinguish object consumers from its generated permission transport. */ +import { expect, expectTypeOf, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import * as Cua from '../src/index.ts' +import type { CuaApi } from '@deepseek-ai/dsh-cua/api' +import type { CuaApplicationState } from '@deepseek-ai/dsh-cua/types' +// @ts-expect-error Permission payloads are not part of the platform-provider root entry. +import type { CuaSystemPermissionState as RemovedRoot } from '@deepseek-ai/dsh-cua' +// @ts-expect-error The object API does not expose permission-page state. +import type { CuaSystemPermissionState as RemovedApi } from '@deepseek-ai/dsh-cua/api' +// @ts-expect-error Object interfaces use /api; /types is the generated Remote type entry. +import type { CuaApi as RemovedTypes } from '@deepseek-ai/dsh-cua/types' +// @ts-expect-error The nested grant structure is internal, not an independently exported type. +import type { CuaApplicationGrant as RemovedGrant } from '@deepseek-ai/dsh-cua/types' + +it('keeps application operations and the necessary Remote payload types available', () => { + expectTypeOf().toHaveProperty('getApp') + expectTypeOf().toHaveProperty('allowedApps') + expectTypeOf<[RemovedRoot, RemovedApi, RemovedTypes, RemovedGrant]>().toBeArray() +}) + +it('mounts the Host marker without installing native or browser services', async () => { + const ctx = new Context() + try { + await ctx.plugin(Cua) + expect(ctx.get('cuaAccess')).toBeUndefined() + expect(ctx.get('computerUse')).toBeUndefined() + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/experimental/cua/tests/exports.built.e2e.ts b/packages/experimental/cua/tests/exports.built.e2e.ts new file mode 100644 index 0000000000..9300b946d5 --- /dev/null +++ b/packages/experimental/cua/tests/exports.built.e2e.ts @@ -0,0 +1,18 @@ +/** Published CUA entries retain headless isolation and the existing generated transport. */ +import { execFile } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { expect, it, onTestFinished } from 'vitest' + +it('loads the built Host, object API and native exports without Client dependencies', async () => { + const fixture = fileURLToPath(new URL('./fixtures/exports.mjs', import.meta.url)) + const launch = resolveExampleLaunch({ srcBin: fixture, libBin: fixture, mode: 'lib' }) + const controller = new AbortController() + const child = promisify(execFile)(launch.command, launch.args, { signal: controller.signal }) + onTestFinished(async () => { + controller.abort() + await child.catch(() => { /* A failed or cancelled fixture is asserted by the test. */ }) + }) + expect((await child).stdout).toBe('headless exports and generated Remote passed\n') +}) diff --git a/packages/experimental/cua/tests/fixtures/exports.mjs b/packages/experimental/cua/tests/fixtures/exports.mjs new file mode 100644 index 0000000000..aa1625007c --- /dev/null +++ b/packages/experimental/cua/tests/fixtures/exports.mjs @@ -0,0 +1,28 @@ +/** Built package smoke: headless consumers must not import the browser implementation. */ +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' + +const guard = registerHooks({ + resolve(specifier, context, next) { + assert(!/^(?:react(?:-dom)?(?:\/|$)|@deepseek-ai\/dsh-client-)/.test(specifier), `Headless import reached ${specifier}`) + const result = next(specifier, context) + assert(!/\/cua\/lib\/(?:client(?:\/|\.)|types\/client\/)/.test(result.url), `Headless import reached ${result.url}`) + return result + }, +}) +const { Context } = await import('@deepseek-ai/cordis') +const host = await import('@deepseek-ai/dsh-cua') +const { createCuaApi } = await import('@deepseek-ai/dsh-cua/api') +const { connectCua } = await import('@deepseek-ai/dsh-cua/bridge') +const { NativeClient } = await import('@deepseek-ai/dsh-cua/native-client') +const { Request } = await import('@deepseek-ai/dsh-cua/protocol') +for (const callable of [host.apply, host.installCua, host.CuaAccess, createCuaApi, connectCua, NativeClient]) assert.equal(typeof callable, 'function') +assert.equal(Request.parse({ method: 'getCapabilities', params: {} }).method, 'getCapabilities') +const ctx = new Context() +try { await ctx.plugin(host); assert.equal(ctx.get('cuaAccess'), undefined) } +finally { await ctx.fiber.dispose() } +guard.deregister() +const remote = await import('@deepseek-ai/dsh-cua/remote') +assert.equal(remote.default.package, '@deepseek-ai/dsh-cua') +assert.equal(remote.default.descriptors.length, 9) +process.stdout.write('headless exports and generated Remote passed\n') diff --git a/packages/experimental/cua/tests/mount.client.spec.ts b/packages/experimental/cua/tests/mount.client.spec.ts new file mode 100644 index 0000000000..02145516f6 --- /dev/null +++ b/packages/experimental/cua/tests/mount.client.spec.ts @@ -0,0 +1,192 @@ +/** The optional permission namespace and existing page slots share one disposal lifetime. */ +import assert from 'node:assert/strict' +import { setImmediate } from 'node:timers/promises' +import { Context, Service } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError, type RemoteResult, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import type { CuaApplicationState, CuaSystemPermissionState } from '../src/access-types.ts' +import { expect, it, vi } from 'vitest' +import type { PermissionFace } from '../src/client/controller.ts' +import { mountCuaPermissions, inject } from '../src/client/mount.ts' + +const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-cua', descriptors: [] } +const value: CuaApplicationState & { system: CuaSystemPermissionState } = { + system: { accessibility: false, screenRecording: false, helper: { pid: 2 }, parent: { pid: 1 } }, + allowedApps: [], blockedApps: [], settingsNamespace: 'cua', persistentAvailable: true } + +function actionsOf(value: Record): asserts value is Record & PermissionFace { + assert(typeof value.refresh === 'function' && typeof value.request === 'function' && typeof value.openSettings === 'function') + assert(typeof value.revoke === 'function' && typeof value.dismissToast === 'function') + assert(typeof value.retryApproval === 'function') + assert(typeof value.hooks === 'object' && value.hooks !== null) +} + +async function fixture(fail = false) { + const ctx = new Context(), unmount = vi.fn(async () => {}), unsubscribe = vi.fn() + let settingsListener: ((namespace: string, revision: number) => void) | undefined + class Remote extends Service { + constructor() { super(ctx, 'remote') } + async $mount(received: TypertRemoteContribution) { + expect(received).toBe(contribution) + const owned = this.ctx.effect(() => unmount) + return () => owned() + } + $on(_event: string, listener: (namespace: string, revision: number) => void) { settingsListener = listener; return unsubscribe } + } + new Remote() + const setDefault = vi.fn(async () => true) + const setPersistent = vi.fn<() => Promise>>().mockResolvedValue({ ok: true, value: undefined }) + const runtimeConfiguration = vi.fn<() => Promise>>() + .mockResolvedValue({ ok: true, value: { persistent: false, writable: true } }) + const preset = createSnapshotStore({ status: 'ready' as const, value: { default: 'standard' }, + base: {}, user: {}, revision: 1, writable: true }) + ctx.provide('configForms', { get: (namespace: string) => { + expect(namespace).toBe('agent-preset-registry') + return { ...preset, set: setDefault } + } }) + const status = vi.fn<() => Promise>>().mockResolvedValue({ ok: true, value }) + const systemStatus = vi.fn<() => Promise>>().mockResolvedValue({ ok: true, value: value.system }) + const applicationIcons = vi.fn<() => Promise>>>().mockResolvedValue({ ok: true, value: {} }) + const requestPermission = vi.fn<() => Promise>>() + .mockResolvedValue({ ok: true, value: value.system }) + const openSettings = vi.fn<() => Promise>>().mockResolvedValue({ ok: true, value: undefined }) + const revoke = vi.fn<() => Promise>>().mockResolvedValue({ ok: true, value: undefined }) + const retryApproval = vi.fn<() => Promise>>().mockResolvedValue({ ok: true, value: undefined }) + ctx.provide('remote.cuaAccess', { + ptcConfiguration: runtimeConfiguration, enablePersistentPtc: setPersistent, + applications: status, systemStatus, applicationIcons, requestPermission, openSettings, revoke, retryApproval }) + ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(SlotRegistry) + ctx.slots.register({ name: 'root', children: { + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, + } } as never, () => null) + if (fail) vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot unavailable') }) + return { ctx, unmount, unsubscribe, status, systemStatus, applicationIcons, + requestPermission, openSettings, revoke, retryApproval, setDefault, setPersistent, runtimeConfiguration, + updateSettings(namespace: string) { settingsListener!(namespace, 1) }, + } +} + +it('withdraws the permission Remote and all controls with the optional plugin', async () => { + const f = await fixture() + try { + const fiber = f.ctx.plugin({ inject: [...inject], apply: ctx => mountCuaPermissions(ctx, contribution) }) + await fiber + const entry = f.ctx.slots.entries('plugins.bundle.config')[0]! + const actions = entry.inject!() + actionsOf(actions) + actions.refresh() + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().applications.value).toEqual(value) }) + actions.request('accessibility') + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().toast?.kind).toBe('requested') }) + actions.openSettings('screenRecording') + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().toast?.kind).toBe('opened') }) + actions.revoke('a'.repeat(64) as import('../src/access-types.ts').CuaApplicationId) + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().toast?.kind).toBe('revoked') }) + actions.retryApproval('a'.repeat(64) as import('../src/access-types.ts').CuaApplicationId) + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().toast?.kind).toBe('retryAllowed') }) + const failed = { ok: false as const, error: new RemoteError('gateway/internal', 'offline', {}) } + for (const [target, invoke] of [ + [f.runtimeConfiguration, actions.refresh], [f.status, actions.refresh], [f.systemStatus, actions.refresh], + [f.applicationIcons, actions.refresh], [f.requestPermission, () => { actions.request('accessibility') }], + [f.openSettings, () => { actions.openSettings('accessibility') }], + [f.revoke, () => { actions.revoke('a'.repeat(64) as import('../src/access-types.ts').CuaApplicationId) }], + [f.retryApproval, () => { actions.retryApproval('a'.repeat(64) as import('../src/access-types.ts').CuaApplicationId) }], + ] as const) { + actions.refresh() + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().busy).toBe(false) }) + target.mockResolvedValueOnce(failed) + invoke() + await vi.waitFor(() => { + const state = actions.hooks.cuaPermissions.getSnapshot() + const error = target === f.runtimeConfiguration ? state.runtime.error : target === f.status ? state.applications.error + : target === f.systemStatus ? state.system.error + : target === f.applicationIcons ? state.icons.error : state.toast?.kind === 'failed' ? state.toast.message : null + expect(error).toContain('offline') + }) + } + for (const name of ['plugins.bundle.activation', 'shell.overlay'] as const) { + const other = f.ctx.slots.entries(name)[0]! + const otherActions = other.inject!() + actionsOf(otherActions) + expect(otherActions.hooks.cuaPermissions).toBe(actions.hooks.cuaPermissions) + } + const settings = f.ctx.slots.entries('plugins.bundle.config')[0]!.inject!() + assert(typeof settings.setPtcDefault === 'function') + expect(f.ctx.slots.entries('plugins.bundle.activation')[0]!.inject!()).not.toHaveProperty('setPtcDefault') + expect(f.setDefault).not.toHaveBeenCalled() + await (settings.setPtcDefault as () => Promise)() + expect(f.setDefault).toHaveBeenCalledExactlyOnceWith('selectedDefault', 'ptc') + assert(typeof settings.setPersistentPtc === 'function') + expect(f.ctx.slots.entries('plugins.bundle.activation')[0]!.inject!()).not.toHaveProperty('setPersistentPtc') + expect(f.setPersistent).not.toHaveBeenCalled() + await (settings.setPersistentPtc as () => Promise)() + expect(f.setPersistent).toHaveBeenCalledExactlyOnceWith() + f.setPersistent.mockResolvedValueOnce(failed) + await expect((settings.setPersistentPtc as () => Promise)()).rejects.toThrow('offline') + await fiber.dispose() + expect(f.ctx.slots.entries('conversation.composer')).toHaveLength(0) + expect(f.ctx.slots.entries('plugins.bundle.config')).toHaveLength(0) + expect(f.ctx.slots.entries('shell.overlay')).toHaveLength(0) + expect(f.unsubscribe).toHaveBeenCalledOnce() + expect(f.unmount).toHaveBeenCalledOnce() + } finally { await f.ctx.fiber.dispose() } +}) + +it('withdraws the Remote contribution when UI registration fails', async () => { + const f = await fixture(true) + try { + await expect(mountCuaPermissions(f.ctx, contribution)).rejects.toThrow('slot unavailable') + expect(f.unmount).toHaveBeenCalledOnce() + } finally { await f.ctx.fiber.dispose() } +}) + +it.each(['plugin', 'root'])('keeps the Remote mounted until an accepted revoke completes during %s disposal', async (owner) => { + const f = await fixture() + const response = Promise.withResolvers>(), entered = Promise.withResolvers() + f.revoke.mockImplementationOnce(() => { entered.resolve(undefined); return response.promise }) + f.unmount.mockImplementation(async () => { response.resolve({ ok: false, error: new RemoteError('gateway/internal', 'unmounted', {}) }) }) + let closing: Promise | undefined + try { + const fiber = await f.ctx.plugin({ inject: [...inject], apply: ctx => mountCuaPermissions(ctx, contribution) }) + const actions = f.ctx.slots.entries('plugins.bundle.config')[0]!.inject!() + actionsOf(actions) + actions.revoke('a'.repeat(64) as import('../src/access-types.ts').CuaApplicationId) + await entered.promise + let closed = false + closing = Promise.resolve(owner === 'plugin' ? fiber.dispose() : f.ctx.fiber.dispose()).then(() => { closed = true }) + await setImmediate() + expect(f.unmount).not.toHaveBeenCalled() + expect(closed).toBe(false) + response.resolve({ ok: true, value: undefined }) + await closing + expect(f.unmount).toHaveBeenCalledOnce() + } finally { + response.resolve({ ok: true, value: undefined }) + await closing + await f.ctx.fiber.dispose() + } +}) + +it('refreshes only the settings namespace reported by the permission owner', async () => { + const f = await fixture() + f.status.mockResolvedValue({ ok: true, value: { ...value, settingsNamespace: 'custom-cua-entry' } }) + try { + await f.ctx.plugin({ inject: [...inject], apply: ctx => mountCuaPermissions(ctx, contribution) }) + const actions = f.ctx.slots.entries('plugins.bundle.config')[0]!.inject!() + actionsOf(actions) + f.updateSettings('locale') + expect(f.status).not.toHaveBeenCalled() + actions.refresh() + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().busy).toBe(false) }) + expect(f.status).toHaveBeenCalledOnce() + f.updateSettings('cua') + f.updateSettings('locale') + expect(f.status).toHaveBeenCalledOnce() + f.updateSettings('custom-cua-entry') + await vi.waitFor(() => { expect(f.status).toHaveBeenCalledTimes(2) }) + } finally { await f.ctx.fiber.dispose() } +}) diff --git a/packages/experimental/cua/tests/panel.client.spec.tsx b/packages/experimental/cua/tests/panel.client.spec.tsx new file mode 100644 index 0000000000..6c2b06668f --- /dev/null +++ b/packages/experimental/cua/tests/panel.client.spec.tsx @@ -0,0 +1,376 @@ +// @vitest-environment jsdom +/** Permission UI distinguishes missing privileges, uncertainty, and application consent. */ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { CuaApplicationId, CuaApplicationState, CuaSystemPermissionState } from '../src/access-types.ts' +import type { ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' +import { afterEach, expect, it, vi } from 'vitest' +import { PermissionPanel, PermissionSetup, PermissionToast, type PermissionPanelProps } from '../src/client/PermissionPanel.tsx' +import type { PermissionView } from '../src/client/controller.ts' +import { en, zh } from '../src/client/locales.ts' + +afterEach(() => { cleanup(); vi.useRealTimers() }) +const value: CuaApplicationState & { system: CuaSystemPermissionState } = { system: { accessibility: false, screenRecording: true, + helper: { pid: 9, path: '/native/helper' }, parent: { pid: 8, path: '/host/node' } }, +allowedApps: [], blockedApps: [], settingsNamespace: 'cua', persistentAvailable: true } + +function observe(value: CuaApplicationState & { system: CuaSystemPermissionState }) { + return { system: { value: value.system, busy: false, error: null }, + applications: { value, busy: false, error: null }, icons: { value: {}, busy: false, error: null }, busy: false } +} + +function fixture() { + const state = createSnapshotStore({ + runtime: { value: { persistent: false, writable: true }, busy: false, error: null }, + system: { value: null, busy: true, error: null }, applications: { value: null, busy: true, error: null }, + icons: { value: null, busy: true, error: null }, busy: true, toast: null, + }) + const preset = createSnapshotStore>({ + status: 'ready', value: { default: 'standard', selectedDefault: 'ptc' }, + base: {}, user: {}, revision: 1, writable: true, + }) + const refresh = vi.fn() + const props = { useCuaPermissions: bindSnapshotSelector(state), t: makeTranslate(en), + setPersistentPtc: vi.fn(async () => true), + useCuaDefaultPreset: bindSnapshotSelector(preset), setPtcDefault: vi.fn(async () => true), + refresh, request: vi.fn(), openSettings: vi.fn(), revoke: vi.fn(), + retryApproval: vi.fn(), dismissToast: vi.fn(), + } satisfies PermissionPanelProps + return { state, preset, props, + publish(patch: Partial) { act(() => { state.set({ ...state.getSnapshot(), ...patch }) }) }, + } +} + +it('requests only a missing privilege and leaves OS attribution to the system prompt', () => { + const f = fixture() + const shown = render() + expect(f.props.refresh).toHaveBeenCalledOnce() + f.publish({ ...observe(value) }) + fireEvent.click(screen.getByRole('button', { name: en.request })) + expect(f.props.request).toHaveBeenCalledWith('accessibility') + expect(screen.queryByRole('button', { name: en.settings })).toBeNull() + const allowed = screen.getByRole('button', { name: en.granted }) + expect(allowed.getAttribute('title')).toBe(en.settings) + fireEvent.click(allowed) + expect(f.props.openSettings.mock.calls).toEqual([['screenRecording']]) + fireEvent.focus(window) + expect(f.props.refresh).toHaveBeenCalledTimes(2) + shown.unmount() + fireEvent.focus(window) + expect(f.props.refresh).toHaveBeenCalledTimes(2) +}) + +it.each([en, zh])('changes the default from the detail page and retains a refused write for retry', async (dictionary) => { + const f = fixture() + f.preset.set({ ...f.preset.getSnapshot(), value: { default: 'standard' } }) + f.publish({ ...observe(value) }) + render() + const group = screen.getByRole('group', { name: dictionary.defaultTitle }) + expect(within(group).getByText(dictionary.defaultBody)).toBeTruthy() + expect(f.props.setPtcDefault).not.toHaveBeenCalled() + const write = Promise.withResolvers() + f.props.setPtcDefault.mockReturnValueOnce(write.promise) + fireEvent.click(within(group).getByRole('button', { name: dictionary.defaultConfirm })) + expect(within(group).getByRole('button', { name: dictionary.defaultConfirm }).hasAttribute('disabled')).toBe(true) + await act(async () => { write.resolve(false); await write.promise }) + expect(screen.getByRole('alert').textContent).toBe(dictionary.defaultNotSaved) + fireEvent.click(within(group).getByRole('button', { name: dictionary.defaultConfirm })) + await waitFor(() => { expect(screen.queryByRole('alert')).toBeNull() }) + expect(f.props.setPtcDefault).toHaveBeenCalledTimes(2) + act(() => { f.preset.set({ ...f.preset.getSnapshot(), value: { default: 'standard', selectedDefault: 'ptc' } }) }) + expect(within(group).getByRole('button', { name: dictionary.defaultSet }).hasAttribute('disabled')).toBe(true) +}) + +it('dismisses activation when permissions are ready without changing the default', () => { + const f = fixture(), onDismiss = vi.fn() + f.preset.set({ ...f.preset.getSnapshot(), value: { default: 'standard', selectedDefault: 'minimal' } }) + f.publish({ ...observe({ ...value, system: { ...value.system, accessibility: true } }) }) + render() + expect(onDismiss).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog')).toBeNull() + expect(f.props.setPtcDefault).not.toHaveBeenCalled() +}) + +it.each([new Error('offline'), 'offline'])('reports a failed default-mode request on the detail page', async (error) => { + const f = fixture() + f.preset.set({ ...f.preset.getSnapshot(), value: { default: 'standard' } }) + f.props.setPtcDefault.mockRejectedValueOnce(error) + render() + fireEvent.click(screen.getByRole('button', { name: en.defaultConfirm })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toContain('offline') }) +}) + +it('waits for the settings document and recognizes PTC as the deployment default', () => { + const f = fixture() + f.preset.set({ ...f.preset.getSnapshot(), status: 'loading', value: undefined }) + render() + expect(screen.queryByRole('button', { name: en.defaultConfirm })).toBeNull() + act(() => { f.preset.set({ ...f.preset.getSnapshot(), status: 'ready', value: { default: 'ptc' } }) }) + expect(screen.getByRole('button', { name: en.defaultSet }).hasAttribute('disabled')).toBe(true) + expect(f.props.setPtcDefault).not.toHaveBeenCalled() +}) + +it.each(['unavailable', 'ready'] as const)('explains why the %s settings document cannot change the default', (status) => { + const f = fixture() + f.preset.set({ ...f.preset.getSnapshot(), status, writable: false, + value: status === 'ready' ? { default: 'standard' } : undefined }) + render() + expect(screen.getByRole('alert').textContent).toBe(status === 'ready' ? en.defaultReadOnly : en.defaultUnavailable) + expect(screen.getByRole('button', { name: en.defaultConfirm }).hasAttribute('disabled')).toBe(true) + expect(f.props.setPtcDefault).not.toHaveBeenCalled() +}) + +it('shows query failures as unknown while retaining previously displayed apps', () => { + const f = fixture(), app = { id: 'a'.repeat(64) as CuaApplicationId, name: 'Fixture', executablePath: '/fixture/app' } + render() + f.publish({ ...observe({ ...value, allowedApps: [app] }), system: { value: value.system, busy: false, error: 'offline' }, busy: false }) + expect(screen.getAllByRole('button', { name: en.revoke })).toHaveLength(1) + expect(screen.getAllByText(en.unknown)).toHaveLength(2) + expect(screen.queryByText(en.granted)).toBeNull() + expect(screen.getByRole('alert').textContent).toContain('offline') + fireEvent.click(screen.getAllByRole('button', { name: en.revoke })[0]!) + expect(f.props.revoke).toHaveBeenCalledWith(app.id) + fireEvent.click(screen.getByRole('button', { name: en.retry })) + expect(f.props.refresh).toHaveBeenCalledTimes(2) + f.publish({ system: { ...f.state.getSnapshot().system, busy: true }, busy: true }) + expect(screen.queryByRole('button', { name: en.retry })).toBeNull() + f.publish({ system: { ...f.state.getSnapshot().system, busy: false, error: null }, busy: false }) + expect(screen.queryByRole('button', { name: en.retry })).toBeNull() + f.publish(observe(value)) + expect(screen.getByText(en.noApps)).toBeTruthy() + expect(screen.queryByRole('button', { name: en.revoke })).toBeNull() +}) + +it('keeps unknown initial status distinct from a confirmed missing privilege in Chinese', () => { + const f = fixture() + render() + f.publish({ system: { ...f.state.getSnapshot().system, busy: false, error: 'connection lost' }, busy: false }) + expect(screen.queryByText(zh.granted)).toBeNull() + expect(screen.queryByRole('button', { name: zh.request })).toBeNull() + f.publish({ ...observe({ ...value, system: { ...value.system, accessibility: true, screenRecording: false } }) }) + fireEvent.click(screen.getByRole('button', { name: zh.request })) + expect(f.props.request).toHaveBeenCalledWith('screenRecording') +}) + +it('offers setup after inspection, permits dismissal, and dismisses once both privileges are observed', () => { + const f = fixture(), onDismiss = vi.fn(), onOpenDetails = vi.fn() + render() + expect(screen.queryByRole('dialog')).toBeNull() + f.publish({ ...observe(value) }) + const dialog = screen.getByRole('dialog', { name: en.setupTitle }) + fireEvent.click(within(dialog).getByRole('button', { name: en.openSetup })) + expect(onOpenDetails).toHaveBeenCalledOnce() + fireEvent.click(within(dialog).getByRole('button', { name: en.later })) + expect(onDismiss).toHaveBeenCalledOnce() + f.publish({ ...observe({ ...value, system: { ...value.system, accessibility: true } }) }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(onDismiss).toHaveBeenCalledTimes(2) +}) + +it('offers the details page when permission inspection fails', () => { + const f = fixture() + render() + f.publish({ system: { ...f.state.getSnapshot().system, busy: false, error: 'offline' }, busy: false }) + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByText(en.setupQueryFailed)).toBeTruthy() + expect(screen.queryByText(en.setupBody)).toBeNull() +}) + +it('exposes a new-request action for a blocked application without calling grant or revoke', () => { + const f = fixture(), app = { id: 'a'.repeat(64) as CuaApplicationId, name: 'Fixture', executablePath: '/fixture/app' } + render() + f.publish({ ...observe({ ...value, blockedApps: [app] }) }) + expect(screen.queryByText(en.noApps)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.retryApproval })) + expect(f.props.retryApproval).toHaveBeenCalledWith(app.id) + expect(f.props.revoke).not.toHaveBeenCalled() + expect(f.props.request).not.toHaveBeenCalled() +}) + +it('renders action outcomes outside the permission page and clears finished toasts', async () => { + vi.useFakeTimers() + const f = fixture() + render() + expect(screen.queryByRole('alert')).toBeNull() + f.publish({ toast: { id: 1, kind: 'revoked' } }) + expect(screen.getByRole('alert').textContent).toBe(en.revoked) + await act(async () => { await vi.advanceTimersByTimeAsync(4000) }) + expect(f.props.dismissToast).toHaveBeenCalledOnce() + f.publish({ toast: { id: 2, kind: 'failed', message: 'write refused' } }) + expect(screen.getByRole('alert').textContent).toContain('write refused') +}) + +it('keeps permission guidance open while a fresh observation is pending', () => { + const f = fixture() + render() + f.publish({ ...observe(value) }) + const dialog = screen.getByRole('dialog') + f.publish({ system: { ...f.state.getSnapshot().system, busy: true }, busy: true }) + expect(screen.getByRole('dialog')).toBe(dialog) +}) + +it('shows application icons and names while keeping the executable path in the tooltip', () => { + const f = fixture() + render() + f.publish({ ...observe({ ...value, allowedApps: [{ id: 'a'.repeat(64) as CuaApplicationId, + name: 'Calculator', executablePath: '/Applications/Calculator.app/Contents/MacOS/Calculator' }] }), + icons: { value: { '/Applications/Calculator.app/Contents/MacOS/Calculator': 'data:image/png;base64,aWNvbg==' }, busy: false, error: null }, busy: false }) + expect(screen.getByText('Calculator')).toBeTruthy() + expect(screen.queryByText('/Applications/Calculator.app/Contents/MacOS/Calculator')).toBeNull() + const identity = screen.getByTitle('/Applications/Calculator.app/Contents/MacOS/Calculator') + expect(identity.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,aWNvbg==') +}) + +it.each([en, zh])('shows four requirements with the target application beside each system permission', (dictionary) => { + const f = fixture() + render() + const application = { name: 'Visual Studio Code.app', path: '/Applications/Visual Studio Code.app', icon: 'data:image/png;base64,aWNvbg==' } + f.publish({ ...observe({ ...value, system: { ...value.system, parent: { pid: 1, path: '/sbin/launchd' }, launchApplication: application } }) }) + const requirements = screen.getByRole('region', { name: dictionary.title }) + expect(within(requirements).getAllByRole('group')).toHaveLength(4) + expect(screen.getByRole('region', { name: dictionary.applicationsTitle })).toBeTruthy() + const accessibility = within(requirements).getByRole('group', { name: dictionary.accessibility }) + expect(within(accessibility).getByText(application.name)).toBeTruthy() + expect(accessibility.textContent).toContain(dictionary.accessibilityPermission.replace('{application}', application.name)) + expect(accessibility.querySelector('img')?.getAttribute('src')).toBe(application.icon) + const recording = within(requirements).getByRole('group', { name: dictionary.screenRecording }) + expect(within(recording).getByText(application.name)).toBeTruthy() + expect(recording.textContent).toContain(dictionary.screenRecordingPermission.replace('{application}', application.name)) + expect(within(requirements).getByText(dictionary.permissionHint)).toBeTruthy() + f.publish({ system: { ...f.state.getSnapshot().system, error: 'offline' } }) + expect(screen.queryByText(application.name)).toBeNull() + expect(within(requirements).getByText(dictionary.permissionHintUnknown)).toBeTruthy() +}) + +it('names the authorization target even when its icon is unavailable', () => { + const f = fixture() + render() + f.publish({ ...observe({ ...value, system: { ...value.system, + launchApplication: { name: 'iTerm.app', path: '/Applications/iTerm.app' } } }) }) + const accessibility = screen.getByRole('group', { name: en.accessibility }) + expect(within(accessibility).getByText('iTerm.app')).toBeTruthy() + expect(accessibility.querySelector('img')).toBeNull() +}) + +it('waits for the first current observation instead of opening setup with a stale error', () => { + const f = fixture() + render() + f.publish({ system: { ...f.state.getSnapshot().system, busy: true, error: 'provider is reloading' }, busy: true }) + expect(screen.queryByRole('dialog')).toBeNull() + f.publish({ ...observe(value) }) + expect(screen.getByRole('dialog', { name: en.setupTitle })).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() +}) + + +it('omits setup instructions once both system permissions are granted', () => { + const f = fixture() + render() + f.publish({ ...observe({ ...value, system: { ...value.system, accessibility: true, screenRecording: true } }) }) + expect(screen.getAllByText(en.granted)).toHaveLength(2) + expect(screen.queryByText(en.permissionHintUnknown)).toBeNull() +}) + + +it('keeps all four requirements visible before the first permission response', () => { + const f = fixture() + render() + const requirements = screen.getByRole('region', { name: en.title }) + expect(within(requirements).getAllByRole('group')).toHaveLength(4) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('button', { name: en.request })).toBeNull() + for (const permission of [en.accessibility, en.screenRecording]) expect(within(screen.getByRole('group', { name: permission })).queryByRole('button')).toBeNull() +}) + +it('fills application names, system permissions, and icons as their observations arrive', () => { + const f = fixture() + render() + const requirements = screen.getByRole('region', { name: en.title }) + const access = screen.getByRole('region', { name: en.applicationsTitle }) + const app = { id: 'a'.repeat(64) as CuaApplicationId, name: 'Calculator', executablePath: '/Applications/Calculator.app' } + f.publish({ applications: { value: { ...value, allowedApps: [app] }, busy: false, error: null } }) + expect(within(access).getByText(app.name)).toBeTruthy() + expect(within(access).getByRole('button', { name: en.revoke }).hasAttribute('disabled')).toBe(false) + expect(screen.queryByText(en.granted)).toBeNull() + const identity = within(access).getByTitle(app.executablePath) + expect(identity.querySelector('img')).toBeNull() + const placeholder = identity.querySelector('[aria-hidden]')! + expect(placeholder).toBeTruthy() + f.publish({ system: { value: value.system, busy: false, error: null } }) + expect(within(requirements).getByText(en.granted)).toBeTruthy() + expect(within(requirements).getByRole('button', { name: en.request }).hasAttribute('disabled')).toBe(false) + expect(within(access).getByTitle(app.executablePath)).toBe(identity) + expect(identity.querySelector('[aria-hidden]')).toBe(placeholder) + f.publish({ icons: { value: { [app.executablePath]: 'data:image/png;base64,aWNvbg==' }, busy: false, error: null } }) + expect(identity.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,aWNvbg==') + expect(identity.querySelector('[aria-hidden]')).toBeNull() + expect(screen.getByRole('region', { name: en.title })).toBe(requirements) +}) + +it.each(['applications', 'icons'] as const)('contains a failed %s query in the application section', (key) => { + const f = fixture() + f.publish({ ...observe(value), [key]: { value: null, busy: false, error: 'offline' } }) + render() + const requirements = screen.getByRole('region', { name: en.title }) + const access = screen.getByRole('region', { name: en.applicationsTitle }) + expect(within(requirements).getByText(en.granted)).toBeTruthy() + expect(within(requirements).queryByRole('alert')).toBeNull() + expect(within(access).getByRole('alert').textContent).toContain('offline') + fireEvent.click(within(access).getByRole('button', { name: en.retry })) + expect(f.props.refresh).toHaveBeenCalledTimes(2) +}) + +it('finishes activation guidance without waiting for application icons', () => { + const f = fixture(), onDismiss = vi.fn() + render() + f.publish({ system: { value: { ...value.system, accessibility: true }, busy: false, error: null } }) + expect(onDismiss).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog')).toBeNull() + expect(f.state.getSnapshot().icons.busy).toBe(true) +}) + +it.each([en, zh])('saves persistent PTC only from its own row and retains failures for retry', async (dictionary) => { + const f = fixture() + render() + const row = screen.getByRole('group', { name: dictionary.persistentTitle }) + expect(f.props.setPersistentPtc).not.toHaveBeenCalled() + const write = Promise.withResolvers() + f.props.setPersistentPtc.mockReturnValueOnce(write.promise) + fireEvent.click(within(row).getByRole('button', { name: dictionary.persistentConfirm })) + expect(within(row).getByRole('button').hasAttribute('disabled')).toBe(true) + await act(async () => { write.resolve(false); await write.promise }) + expect(within(row).getByRole('alert').textContent).toBe(dictionary.persistentNotSaved) + fireEvent.click(within(row).getByRole('button')) + await waitFor(() => { expect(within(row).queryByRole('alert')).toBeNull() }) + f.publish({ runtime: { value: { persistent: true, writable: true }, busy: false, error: null } }) + expect(within(row).getByRole('button', { name: dictionary.persistentSet }).hasAttribute('disabled')).toBe(true) + expect(f.props.setPtcDefault).not.toHaveBeenCalled() +}) + +it('loads the runtime setting independently and reports unavailable and read-only configurations', () => { + const f = fixture() + f.publish({ runtime: { value: null, busy: true, error: null } }) + render() + const row = screen.getByRole('group', { name: en.persistentTitle }) + expect(within(row).queryByRole('button')).toBeNull() + expect(screen.getByRole('button', { name: en.defaultSet })).toBeTruthy() + f.publish({ runtime: { value: null, busy: false, error: 'offline' } }) + expect(within(row).getByRole('alert').textContent).toBe(en.persistentUnavailable) + f.publish({ runtime: { value: { persistent: false, writable: false }, busy: false, error: null } }) + expect(within(row).getByRole('alert').textContent).toBe(en.persistentReadOnly) + expect(within(row).getByRole('button').hasAttribute('disabled')).toBe(true) + expect(f.props.setPersistentPtc).not.toHaveBeenCalled() +}) + + +it('retains the previous single control while a stale query error is being refreshed', () => { + const f = fixture() + f.publish(observe(value)) + render() + f.publish({ system: { value: value.system, busy: true, error: 'stale error' }, busy: true }) + expect(screen.getByRole('button', { name: en.request }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: en.granted }).hasAttribute('disabled')).toBe(true) + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/packages/experimental/cua/tsconfig.client.json b/packages/experimental/cua/tsconfig.client.json new file mode 100644 index 0000000000..03db4207e8 --- /dev/null +++ b/packages/experimental/cua/tsconfig.client.json @@ -0,0 +1,56 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src/client", + "src/css-modules.d.ts", + "src/access-types.ts", + "src/application-types.ts", + "src/remote-types.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../client/locale/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../client/ui-primitives" + }, + { + "path": "../../client/ui-renderer" + }, + { + "path": "../../client/ui-slots" + }, + { + "path": "../../client/ui-plugin-manager/tsconfig.client.json" + }, + { + "path": "../../client/ui-settings/tsconfig.client.json" + }, + { + "path": "../../client/ui-layout" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/gateway/tsconfig.client.json" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/brand" + } + ] +} diff --git a/packages/experimental/cua/tsconfig.host.json b/packages/experimental/cua/tsconfig.host.json new file mode 100644 index 0000000000..ebeaed4918 --- /dev/null +++ b/packages/experimental/cua/tsconfig.host.json @@ -0,0 +1,73 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "include": [ + "src/*.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../computer-use/computer-use" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/values" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/atomic-write" + }, + { + "path": "../../ptc-runtime/ptc-runtime" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../interaction/user-questions" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../preset/agent-preset-registry" + } + ], + "exclude": [ + "src/css-modules.d.ts" + ] +} diff --git a/packages/experimental/cua/tsconfig.json b/packages/experimental/cua/tsconfig.json index a73c2cfb2e..2eca820546 100644 --- a/packages/experimental/cua/tsconfig.json +++ b/packages/experimental/cua/tsconfig.json @@ -1,60 +1,11 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../../vendor/cordis" + "path": "./tsconfig.host.json" }, { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../computer-use/computer-use" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../subprocess/subprocess" - }, - { - "path": "../../util/values" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../util/atomic-write" - }, - { - "path": "../../ptc-runtime/ptc-runtime" - }, - { - "path": "../../settings/settings" - }, - { - "path": "../../interaction/user-questions" - }, - { - "path": "../../../vendor/loader" - }, - { - "path": "../../attachment/attachment" - }, - { - "path": "../../llm/llm" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/experimental/cua/tsdown.config.ts b/packages/experimental/cua/tsdown.config.ts index ccc53ca43d..5dac622abb 100644 --- a/packages/experimental/cua/tsdown.config.ts +++ b/packages/experimental/cua/tsdown.config.ts @@ -1,10 +1,13 @@ -import { defineConfig } from 'tsdown' +import type { UserConfig } from 'tsdown' +import { clientBundle } from '../../client/tsdown.client.ts' -export default defineConfig(Object.entries({ - index: 'lib/types/index.js', api: 'lib/types/api.js', bridge: 'lib/types/bridge.js', +const runtime: UserConfig[] = Object.entries({ + 'remote-types': 'lib/types/remote-types.js', api: 'lib/types/api.js', bridge: 'lib/types/bridge.js', 'native-client': 'lib/types/native-client.js', protocol: 'lib/types/protocol.js', }).map(([name, path]) => ({ entry: { [name]: path }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, -}))) +})) + +export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { hostPhase: true, companions: runtime }) diff --git a/packages/experimental/hooks-claude-code/package.json b/packages/experimental/hooks-claude-code/package.json index 00a8586395..e6f2c522f4 100644 --- a/packages/experimental/hooks-claude-code/package.json +++ b/packages/experimental/hooks-claude-code/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { diff --git a/packages/experimental/hooks-claude-code/src/index.ts b/packages/experimental/hooks-claude-code/src/index.ts index afbc60de08..5e2b59d3d8 100644 --- a/packages/experimental/hooks-claude-code/src/index.ts +++ b/packages/experimental/hooks-claude-code/src/index.ts @@ -12,8 +12,7 @@ import type {} from '@deepseek-ai/dsh-working-directory' import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-llm' { @@ -43,9 +42,9 @@ import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts' export const name = 'hooks-claude-code' -// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest +// `shell` runs hooks and `agents` supplies turn numbers; the rest // are read opportunistically via ctx.get so a deployment can omit them. -export const inject = ['shell', 'sessionProjections', 'workingDirectory'] +export const inject = ['shell', 'agents', 'workingDirectory'] /** Plugin config: where the CC hook config lives + substitution roots. */ export interface Config { @@ -317,8 +316,7 @@ const SUBAGENT_TYPE = 'general-purpose' /** The last open turn number in the agent's log, or 0 without an agent. */ function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 - const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') as TurnBoundaryProjection - return boundary.lastTurn + return ctx.agents.turnBoundaryOf(agent.session).lastTurn } /** Flatten content blocks to the text a hook payload carries (the common case). */ diff --git a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts index f6b012bc28..3096a8459f 100644 --- a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts @@ -467,12 +467,12 @@ describe('hooks-claude-code bridge — load resilience', () => { // "cannot get property … without inject". Guard the shape directly. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude-code') - expect(HooksClaude.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(HooksClaude.inject).toEqual(['shell', 'agents', 'workingDirectory']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksClaude) as Record expect(unwrapped).toBe(HooksClaude) expect(unwrapped.name).toBe('hooks-claude-code') - expect(unwrapped.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(unwrapped.inject).toEqual(['shell', 'agents', 'workingDirectory']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/experimental/hooks-codex/package.json b/packages/experimental/hooks-codex/package.json index dd1085e504..b07e186907 100644 --- a/packages/experimental/hooks-codex/package.json +++ b/packages/experimental/hooks-codex/package.json @@ -36,7 +36,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { diff --git a/packages/experimental/hooks-codex/src/index.ts b/packages/experimental/hooks-codex/src/index.ts index 139a99de6d..2d9b3c3daf 100644 --- a/packages/experimental/hooks-codex/src/index.ts +++ b/packages/experimental/hooks-codex/src/index.ts @@ -16,7 +16,6 @@ import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-llm' { @@ -44,7 +43,7 @@ import { parseCodexConfig, type CodexHookConfig } from './config.ts' /* jscpd:ignore-end */ export const name = 'hooks-codex' -export const inject = ['shell', 'sessionProjections', 'workingDirectory'] +export const inject = ['shell', 'agents', 'workingDirectory'] /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { @@ -281,8 +280,7 @@ export function apply(ctx: Context, config: Config): void { /* jscpd:ignore-start */ function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 - /* v8 ignore next -- agent-present hook points run inside AgentLoop, which owns this projection. */ - return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 + return ctx.agents.turnBoundaryOf(agent.session).lastTurn } function blocksToText(content: ContentBlock[]): string { diff --git a/packages/experimental/hooks-codex/tests/bridge.spec.ts b/packages/experimental/hooks-codex/tests/bridge.spec.ts index 242eb91819..8089e0c092 100644 --- a/packages/experimental/hooks-codex/tests/bridge.spec.ts +++ b/packages/experimental/hooks-codex/tests/bridge.spec.ts @@ -287,12 +287,12 @@ describe('hooks-codex bridge', () => { it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { expect('default' in HooksCodex).toBe(false) expect(HooksCodex.name).toBe('hooks-codex') - expect(HooksCodex.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(HooksCodex.inject).toEqual(['shell', 'agents', 'workingDirectory']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksCodex) as Record expect(unwrapped).toBe(HooksCodex) expect(unwrapped.name).toBe('hooks-codex') - expect(unwrapped.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(unwrapped.inject).toEqual(['shell', 'agents', 'workingDirectory']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts index b2bf78ddca..648fd59039 100644 --- a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts @@ -1,10 +1,10 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { SessionTitleProviderId, type SessionTitleUserMessage, @@ -92,7 +92,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -215,7 +215,7 @@ describe('all-messages LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) // A one-byte fallback cap cannot derive a title from the multibyte message, // so the service supplies no currentTitle to the provider. await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 80 }) diff --git a/packages/experimental/tool-session-query/package.json b/packages/experimental/tool-session-query/package.json index ea6d231eb3..ad22c3f239 100644 --- a/packages/experimental/tool-session-query/package.json +++ b/packages/experimental/tool-session-query/package.json @@ -31,7 +31,6 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts index 61e18cc8f5..1e1d2f38d2 100644 --- a/packages/experimental/tool-session-query/src/index.ts +++ b/packages/experimental/tool-session-query/src/index.ts @@ -16,7 +16,7 @@ import { presentation } from './presentation.ts' export const name = 'tool-session-query' /** Capability services required by the model-facing consumer. */ -export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections'] +export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'agents'] /** Default maximum number of authorized search hits returned by one call. */ export const DEFAULT_MAX_SEARCH_RESULTS = 100 diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts index 3d5fa17f31..1d10d0a34b 100644 --- a/packages/experimental/tool-session-query/src/operations.ts +++ b/packages/experimental/tool-session-query/src/operations.ts @@ -126,13 +126,7 @@ async function executeEventSearch( const query = toolInput.normalizeQuery(args.query) const range = toolInput.sequenceRange(args.seq_from, args.seq_to) if (sessionId === caller.id) { - const stepStartSeq = caller.boundary?.lastStepStartSeq - if (stepStartSeq === undefined) { - throw new HarnessError( - 'current-session search requires an active step boundary', - 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', - ) - } + const stepStartSeq = caller.boundary.lastStepStartSeq // `null` (no step started yet) caps the range to a degenerate `to` the // filter validation rejects — the loop never runs tools outside a step. range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts index 9eabee4e46..60bf265129 100644 --- a/packages/experimental/tool-session-query/src/workspace-access.ts +++ b/packages/experimental/tool-session-query/src/workspace-access.ts @@ -17,14 +17,13 @@ import type { SessionRecord, } from '@deepseek-ai/dsh-session-query' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader /** The caller's own-session boundary fold (the `turnBoundary` projection). */ - readonly boundary: TurnBoundaryProjection | undefined + readonly boundary: TurnBoundaryProjection } interface TitleView { @@ -64,7 +63,7 @@ function callerOf(exec: ToolRunContext, ctx: Context): Caller { return { id: agent.session.id, header: agent.session.header, - boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), + boundary: ctx.agents.turnBoundaryOf(agent.session), } } diff --git a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts index 566e34478a..a140f3c0e1 100644 --- a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, @@ -12,7 +12,6 @@ import SessionStore, { type Session, } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -33,8 +32,8 @@ function fakeAgent(session: Session): Agent { return { id: session.id, session } as unknown as Agent } -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function registerTurnBoundary(ctx: Context): Promise { + await ctx.plugin(AgentRegistry) } describe('tool-session-query with the real SQLite provider', () => { @@ -45,7 +44,7 @@ describe('tool-session-query with the real SQLite provider', () => { contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) @@ -118,7 +117,7 @@ describe('tool-session-query with the real SQLite provider', () => { contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) diff --git a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts index 3d11ad3f85..6dcc87cbb1 100644 --- a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, ToolCallId, HarnessError , createMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS, TimeoutReason } from '@deepseek-ai/dsh-timeout' @@ -15,7 +15,6 @@ import SessionStore, { type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionQueryEngine, { SessionQueryError, SessionSearchCursor, @@ -31,6 +30,7 @@ import SessionQueryEngine, { } from '@deepseek-ai/dsh-session-query' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { Config as ToolSessionQueryConfig } from '@deepseek-ai/dsh-tool-session-query' import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' declare module '@deepseek-ai/dsh-llm' { @@ -207,12 +207,12 @@ interface Mounted { call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise } -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function registerTurnBoundary(ctx: Context): Promise { + await ctx.plugin(AgentRegistry) } async function mount( - config: ToolSessionQuery.Config = {}, + config: ToolSessionQueryConfig = {}, callerCwd: string | null = '/work', enforceTimeout = false, ): Promise { @@ -220,7 +220,7 @@ async function mount( activeContexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) if (enforceTimeout) await ctx.plugin(TimeoutPolicy) @@ -1652,7 +1652,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(text(result)).toContain('Result cap reached') }) - it('rejects current-session search without the turnBoundary fold', async () => { + it('rejects current-session search before any step has started', async () => { const ctx = new Context() activeContexts.push(ctx) await ctx.plugin(SessionStore) @@ -1660,6 +1660,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(FakeQuery) + await ctx.plugin(AgentRegistry) await ctx.plugin(ToolSessionQuery) const session = createSession(ctx, 'no-boundary-caller', '/work') const result = await ctx.tools.execute({ @@ -1669,7 +1670,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => signal: new AbortController().signal, agent: fakeAgent(session), }) - expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined() + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') }) it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => { diff --git a/packages/experimental/tool-session-query/tsconfig.json b/packages/experimental/tool-session-query/tsconfig.json index c3636a6deb..71a98f903b 100644 --- a/packages/experimental/tool-session-query/tsconfig.json +++ b/packages/experimental/tool-session-query/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../../util/timeout" }, - { - "path": "../../session/session-projection" - }, { "path": "../../core/agent" } diff --git a/packages/experimental/tool-terminal/tests/tools.spec.ts b/packages/experimental/tool-terminal/tests/tools.spec.ts index fcc0a10405..3efbaf7ca3 100644 --- a/packages/experimental/tool-terminal/tests/tools.spec.ts +++ b/packages/experimental/tool-terminal/tests/tools.spec.ts @@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools' @@ -114,6 +115,7 @@ async function setupBase(jobs: boolean) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend() diff --git a/packages/experimental/worktree/tests/harness.ts b/packages/experimental/worktree/tests/harness.ts index c38be4d57c..369c13fa2f 100644 --- a/packages/experimental/worktree/tests/harness.ts +++ b/packages/experimental/worktree/tests/harness.ts @@ -71,8 +71,8 @@ export async function repository(parent = tmpdir()): Promise { export async function harness(root: string, config: Config = {}, mode: SandboxMode = 'danger-full-access'): Promise { const ctx = new Context() try { - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(LocalFileSystem, { cwd: root }) await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 50bcbe529a..267ba267d7 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2281,12 +2281,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle', + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-cua-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', slotInject: '', declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', occupants: [ 'experimental-client-ui-voice-input VoiceSetupPrompt key \'@deepseek-ai/dsh-experimental-voice-input-bundle\'', + 'cua PermissionSetup key \'@deepseek-ai/dsh-cua-bundle\'', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -2323,13 +2324,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-cot-translation-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle', + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-cua-bundle, @deepseek-ai/dsh-experimental-cot-translation-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', slotInject: '', declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', occupants: [ 'experimental-client-ui-cot-translation TranslationSettings key \'@deepseek-ai/dsh-experimental-cot-translation-bundle\'', 'experimental-client-ui-voice-input VoicePreparation key \'@deepseek-ai/dsh-experimental-voice-input-bundle\'', + 'cua PermissionPanel key \'@deepseek-ai/dsh-cua-bundle\'', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -2443,8 +2445,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'plugins.detail.section', kind: 'list', scope: 'root', - summary: 'Sections under a detail page\'s own content: after the rows on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.', - doc: 'Sections under a detail page\'s own content: after the rows on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.', + summary: 'Sections under a detail page\'s own content: after the source on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.', + doc: 'Sections under a detail page\'s own content: after the source on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.', registerOptions: [ { name: 'id', @@ -3399,6 +3401,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'', 'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'', 'client-ui-workspace RowActionToast id \'workspace.row-toast\'', + 'cua PermissionToast id \'cua-permission-outcome\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 168ce4a09a..57be03234e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -149,6 +149,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Registry of YAML-declared presets and the revisions live Agents retain.', description: 'Registry of YAML-declared presets and the revisions live Agents retain.', methods: [ + { + signature: 'presetForSession(session: Session): string | null', + description: 'Read the preset recorded for a Session, including selections no longer in the roster.', + parameters: [{ name: 'session', description: 'Session whose committed preset selection is read.' }], + returns: 'the recorded preset, or null when none was selected.', + }, { signature: 'async register(definition: PresetDefinition): Promise<() => Promise>', description: 'Register and eagerly load a definition; activation failure remains visible in the roster.', @@ -240,6 +246,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.', description: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.\n\nInitiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain.', methods: [ + { + signature: 'turnBoundaryOf(session: Session): TurnBoundaryProjection', + description: 'Read the turn and step positions recorded in a Session.', + parameters: [{ name: 'session', description: 'Session whose committed events are read.' }], + returns: 'the current turn and step positions; do not mutate the returned value.', + }, { signature: 'currentInitiator(): Agent | undefined', description: 'Read the Agent that initiated the inherited asynchronous driver chain. Use this optional form for logging, tracing, metrics, or host attribution that also supports agentless calls. When a parent creates a child, setup reports the causal parent while the setup callback\'s Agent parameter identifies the child.', @@ -924,6 +936,67 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'cuaAccess', + summary: 'Browser controls for provider-owned native permission and application decisions.', + description: 'Browser controls for provider-owned native permission and application decisions.', + methods: [ + { + signature: '@Remote ptcConfiguration(): { persistent: boolean; writable: boolean }', + description: 'Read PTC execution state separately from native permission checks.', + parameters: [], + returns: 'Whether the shared runtime retains memory and has an editable profile entry.', + }, + { + signature: '@Remote async enablePersistentPtc(): Promise', + description: 'Persist in-memory PTC execution through the existing profile editor and Loader lifecycle.', + parameters: [], + returns: 'Completion after the shared runtime reload; already-persistent runtimes are left intact.', + }, + { + signature: '@Remote applications(): CuaApplicationState', + description: 'Read application decisions without waiting for the native helper.', + parameters: [], + returns: 'Saved grants and refused applications with their persistence owner.', + }, + { + signature: '@Remote async systemStatus(): Promise', + description: 'Inspect system privileges independently of application decisions and icons.', + parameters: [], + returns: 'Fresh native preflight and launch metadata.', + }, + { + signature: '@Remote async applicationIcons(): Promise>', + description: 'Load presentation icons separately from application decisions.', + parameters: [], + returns: 'Optional native PNG icons keyed by executable path for saved and blocked applications.', + }, + { + signature: '@Remote async requestPermission(permission: CuaSystemPermission): Promise', + description: 'Request a system privilege explicitly selected by the user.', + parameters: [{ name: 'permission', description: 'macOS privilege to request.' }], + returns: 'Preflight state after requesting; a false value never implies consent.', + }, + { + signature: '@Remote async openSettings(permission: CuaSystemPermission): Promise', + description: 'Open the macOS privacy pane selected by the user.', + parameters: [{ name: 'permission', description: 'Corresponding system privilege.' }], + returns: 'Completion after macOS accepted the open request.', + }, + { + signature: '@Remote revoke(id: CuaApplicationId): Promise', + description: 'Remove saved and session access and join affected operations.', + parameters: [{ name: 'id', description: 'Application identity displayed on the permission page.' }], + returns: 'Completion after persistence and cancellation; dispatched input cannot be undone.', + }, + { + signature: '@Remote async retryApproval(id: CuaApplicationId): Promise', + description: 'Clear this application\'s refusals across sessions so each session may ask for access again.', + parameters: [{ name: 'id', description: 'Refused or revoked application displayed on the permission page.' }], + returns: 'Completion after clearing refusals; no permission is granted and no action is replayed.', + }, + ], + }, { key: 'deepseekAccount', summary: 'Account operations; only Host consumers can obtain a request credential.', @@ -1732,7 +1805,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'planMode', summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.', - description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view.', + description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.', methods: [ { signature: 'get(agent: Agent): { active: boolean; pending?: boolean }', @@ -1753,6 +1826,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Manage profile files and apply their declared reload lifecycle.', description: 'Manage profile files and apply their declared reload lifecycle.', methods: [ + { + signature: '@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise', + description: 'Read a bundle\'s long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its README. An installed bundle is read from its directory without network access; an official bundle the installation offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.', + parameters: [{ name: 'name', description: 'the bundle\'s package name, as `listBundles()` lists it.' }, { name: 'language', description: 'the display language, such as `zh-CN`; documents fall back through its prefixes to English.' }, { name: 'options', description: '`npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.' }], + returns: 'the document with its origin and path, or `none`.', + throws: ['{RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or `plugin-manager/detail-unavailable`.'], + }, { signature: '@Remote listVersionExemptions(): { exemptions: Record; warnings: string[] }', description: 'Read exact plugin-version exemptions saved in this profile.', @@ -1789,6 +1869,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'spec', description: 'One package spec: a registry name, an absolute path, a git address, or a tarball.' }, { name: 'options', description: 'The registry asked first.' }, { name: 'signal', description: 'Ends a registry lookup early.' }], returns: 'The package the spec names, or why it is refused.', }, + { + signature: '@Remote async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise', + description: 'Read the published versions newer than an installed registry package\'s version, from the first registry that answers. Official bundles follow the DSH version and are not listed.', + parameters: [{ name: 'name', description: 'Installed profile dependency name.' }, { name: 'options', description: 'The registry asked first; absent, the one the package was installed from in this process, else the configured one.' }, { name: 'signal', description: 'Ends the lookups early.' }], + returns: 'At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration, and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked.', + }, + { + signature: '@Remote async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise', + description: 'Replace an installed registry bundle with a newer published version. The target is checked on the registry first: it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed version is removed, so its plugins stop, and the target is installed with the same selection; when that installation fails or is cancelled, the removed version is installed again from the registry it came from. Without HMR the target is installed over the running version and the result reports the restart.', + parameters: [{ name: 'name', description: 'Installed profile dependency name.' }, { name: 'version', description: 'Exact published version newer than the installed one.' }, { name: 'options', description: 'The registry asked first, as for `listVersions`, and the request id naming the progress and the target installation\'s logs and cancellation.' }], + returns: 'The versions, the last step that ran with its result, and the reinstallation when one ran.', + }, { signature: '@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise', description: 'Persist a plugin entry\'s desired enablement and apply it on live profiles.', @@ -1858,6 +1950,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the plugin with its default version, installation status, and annotated versions; undefined when the index does not list it.', throws: ['{RemoteError} `plugin-market/unknown-index`, `plugin-market/invalid-index-url`, `plugin-market/fetch-failed`, or `plugin-market/invalid-index`.'], }, + { + signature: '@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise', + description: 'Read the long description of one listed version of a plugin market entry, from the version\'s source: the npm file CDNs for a public npm version, or its pinned Git commit. The Markdown file the package\'s locale `meta.detail` names in `language` comes first, then the package\'s README, then the README of the entry\'s `repository`, or for an npm version without one, of its `package.json` `repository`, at the default branch. An npm version from another registry has no readable package files, so only the entry\'s `repository` is read.', + parameters: [{ name: 'url', description: 'a configured index URL.' }, { name: 'name', description: 'the plugin\'s package name.' }, { name: 'version', description: 'one listed version.' }, { name: 'language', description: 'the display language, such as `zh-CN`; documents fall back through its prefixes to English.' }, { name: 'options', description: '`refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not configured are not asked.' }], + returns: 'the document with its origin and image document id, or `none`.', + throws: ['{RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.'], + }, ], }, { @@ -2362,19 +2461,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.', methods: [ { - signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', - description: 'Register one domain\'s unit. The registration is an effect on the calling context\'s fiber: disposing the fiber (or calling the returned disposer) removes the key — and the unit\'s cached cells — from subsequent drives and snapshots.', + signature: 'register( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): Promise>', + description: 'Register one domain\'s unit on the calling context\'s fiber. Fiber or handle disposal releases one reference; the final release removes live participation. Retained handles remain readable after release. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'the exact disposer that unregisters this unit.', + returns: 'a ready handle with synchronous state reads and the effect disposer.', }, { - signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): () => void', - description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit.', + signature: 'register( definition: ProjectionDefinition & { key: Exclude }, ): Promise>', + description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'the exact disposer that unregisters this unit.', + returns: 'a ready handle with synchronous state reads and the effect disposer.', }, { signature: 'onChanged(listener: ProjectionChangeListener): () => void', @@ -2382,12 +2481,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'listener', description: 'called once per client-visible unit whose raw view changed by `Object.is`, per committed event.' }], returns: 'the exact disposer that unsubscribes.', }, - { - signature: 'stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined', - description: 'Read one unit\'s current host state after materializing every registered unit at the Session cursor. Unrelated wire views are not produced. The returned value is live; callers must not mutate it.', - parameters: [{ name: 'session', description: 'the session whose state is read.' }, { name: 'key', description: 'the registered unit key.' }], - returns: 'current state, or `undefined` when the key is not registered.', - }, { signature: 'snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot', description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.', @@ -3422,6 +3515,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', description: 'Replay owner for one service-wide estimator and isolated per-session folds.', methods: [ + { + signature: 'contextPressure(session: Session): Pick', + description: 'Read the latest logged provider prompt size and route capacity.', + parameters: [{ name: 'session', description: 'Session whose committed usage and request capacity are read.' }], + returns: 'context pressure; fields remain absent until their source events are recorded.', + }, { signature: 'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement', description: 'Measure current request pressure and surface through the durable tail.\n\nThe effective envelope\'s routed provider/model selects the request-image pricing every node is priced under: a route whose adapter declares image pricing charges each retained image its visual tokens plus its model-visible text, while other routes keep the fixed heuristic. Provider usage is reused only when the latest successful call\'s canonical request envelope matches `requestHeader` and its total is no lower than that call\'s full route-priced anchor; otherwise the complete envelope and surface are repriced. The anchor includes all surface nodes immediately before the assistant message, including inputs admitted after step/start.\n\n`requestHeader` replaces the latest logged envelope for pressure and node pricing; the node set always describes the current session surface. Every call clones those positional nodes, so measurement is O(surface).', @@ -4469,6 +4568,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'An installation moved between its Host phases. `installing` is announced once per registry the installation asks, with the attempt\'s registry and position; `cancelling` and `applying` once.', parameters: [{ name: 'progress', description: 'the installation\'s request id and phase, with the attempt while installing.' }], }, + { + name: 'plugin-manager/update-state', + mode: 'emit', + signature: '\'plugin-manager/update-state\'(progress: PluginUpdateProgress): void', + summary: 'An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed.', + description: 'An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed. Announced once per step.', + parameters: [{ name: 'progress', description: 'the update\'s request id and step.' }], + }, { name: 'plugin-market/changed', mode: 'emit', @@ -5045,9 +5152,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BrowserUseProviderName', declaration: 'export type BrowserUseProviderName = Branded<\'BrowserUseProviderName\'>;', }, + { + name: 'BundleDetail', + declaration: 'export type BundleDetail = {\n readonly status: \'found\';\n readonly origin: \'locale\' | \'readme\';\n readonly path: string;\n readonly markdown: string;\n} | {\n readonly status: \'none\';\n};', + }, { name: 'BundleInfo', - declaration: 'export interface BundleInfo {\n name: string;\n official: boolean;\n availability: \'installation\' | \'profile\' | \'missing\';\n installTarget?: {\n spec: string;\n version: string;\n };\n version?: string;\n meta?: PluginLocalizedMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n source?: string;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', + declaration: 'export interface BundleInfo {\n name: string;\n official: boolean;\n availability: \'installation\' | \'profile\' | \'missing\';\n installTarget?: {\n spec: string;\n version: string;\n };\n version?: string;\n meta?: PluginLocalizedMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n source?: string;\n optional: boolean;\n removable: boolean;\n updatable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', }, { name: 'BundleRowInfo', @@ -5229,6 +5340,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContextPressureProjection', + declaration: 'export interface ContextPressureProjection {\n pressureTokens?: number;\n projectedTokens?: number;\n contextWindow?: number;\n}', + }, { name: 'ContinuableCreateRequest', declaration: 'export interface ContinuableCreateRequest {\n readonly cwd: string;\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', @@ -5373,6 +5488,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CronScheduleRecord', declaration: 'export interface CronScheduleRecord {\n readonly id: ScheduleId;\n readonly kind: \'cron\';\n readonly title: string;\n readonly prompt: string;\n readonly expression: string;\n readonly timeZone: string;\n readonly scheduledAt: string;\n}', }, + { + name: 'CuaApplicationGrant', + declaration: 'export interface CuaApplicationGrant {\n id: CuaApplicationId;\n name: string;\n executablePath: string;\n}', + }, + { + name: 'CuaApplicationId', + declaration: 'export type CuaApplicationId = Branded<\'CuaApplicationId\'>;', + }, + { + name: 'CuaApplicationState', + declaration: 'export interface CuaApplicationState {\n settingsNamespace: string | null;\n allowedApps: readonly CuaApplicationGrant[];\n blockedApps: readonly CuaApplicationGrant[];\n persistentAvailable: boolean;\n}', + }, + { + name: 'CuaPermissionProcess', + declaration: 'export interface CuaPermissionProcess {\n pid: number;\n path?: string | undefined;\n name?: string | undefined;\n}', + }, + { + name: 'CuaSystemPermission', + declaration: 'export type CuaSystemPermission = \'accessibility\' | \'screenRecording\';', + }, + { + name: 'CuaSystemPermissionState', + declaration: 'export interface CuaSystemPermissionState {\n accessibility: boolean;\n screenRecording: boolean;\n helper: CuaPermissionProcess;\n parent: CuaPermissionProcess;\n launchApplication?: {\n name: string;\n path: string;\n icon?: string | undefined;\n } | undefined;\n}', + }, { name: 'DailyInput', declaration: 'export interface DailyInput {\n readonly time: string;\n readonly time_zone: string;\n}', @@ -5973,6 +6112,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'KvUnitDescriptor', declaration: 'export interface KvUnitDescriptor {\n readonly name: string;\n readonly version: number;\n readonly tables: readonly string[];\n readonly hasGlobal: boolean;\n readonly layout?: \'single\' | \'per-record\';\n readonly compatibleVersions?: readonly number[];\n}', }, + { + name: 'ListVersionsOptions', + declaration: 'export interface ListVersionsOptions {\n readonly registry?: Registry;\n}', + }, { name: 'LlmAdapter', declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;\n imageRequestTarget(_provider: string, _model: string, image: ImageAttachmentRef): ImageRequestTarget;\n listModels(_provider: string): Promise;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise;\n async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', @@ -6365,6 +6508,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'NotFutureError', declaration: 'export interface NotFutureError {\n readonly code: \'not_future\';\n readonly message: string;\n}', }, + { + name: 'NpmFileSource', + declaration: 'export type NpmFileSource = \'jsdelivr\' | \'unpkg\' | \'npmmirror\';', + }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', @@ -6465,6 +6612,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PluginChange', declaration: 'export interface PluginChange {\n readonly reason: \'plugin\' | \'bundle\' | \'install\' | \'remove\';\n}', }, + { + name: 'PluginDetail', + declaration: 'export type PluginDetail = {\n readonly status: \'found\';\n readonly origin: PluginDetailOrigin;\n readonly markdown: string;\n readonly document: PluginDetailDocumentId;\n} | {\n readonly status: \'none\';\n};', + }, + { + name: 'PluginDetailDocumentId', + declaration: 'export type PluginDetailDocumentId = Branded<\'PluginDetailDocumentId\'>;', + }, + { + name: 'PluginDetailOrigin', + declaration: 'export type PluginDetailOrigin = \'locale\' | \'readme\' | \'repository\';', + }, { name: 'PluginEntryId', declaration: 'export type PluginEntryId = Branded<\'PluginEntryId\'>;', @@ -6525,6 +6684,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PluginSpecInspection', declaration: 'export type PluginSpecInspection = {\n readonly status: \'accepted\';\n readonly kind: InstallSpecKind;\n readonly name?: string;\n readonly version?: string;\n readonly description?: string;\n readonly bundle: boolean | null;\n readonly registry: Registry;\n readonly host?: string;\n} | {\n readonly status: \'refused\';\n readonly problem: PluginInspectProblem;\n readonly reason: string;\n readonly registries?: Registry[];\n};', }, + { + name: 'PluginUpdateProgress', + declaration: 'export interface PluginUpdateProgress {\n readonly requestId: PluginInstallRequestId;\n readonly step: PluginUpdateStep;\n}', + }, + { + name: 'PluginUpdateResult', + declaration: 'export interface PluginUpdateResult {\n readonly from: string;\n readonly to: string;\n readonly step: \'check\' | \'remove\' | \'install\';\n readonly result: ChangeResult;\n readonly restore?: ChangeResult;\n}', + }, + { + name: 'PluginUpdateStep', + declaration: 'export type PluginUpdateStep = \'checking\' | \'removing\' | \'installing\' | \'restoring\';', + }, + { + name: 'PluginVersion', + declaration: 'export interface PluginVersion {\n readonly version: string;\n readonly publishedAt?: string;\n readonly tags: readonly string[];\n readonly prerelease: boolean;\n readonly deprecated?: string;\n readonly bundle: boolean;\n readonly incompatible?: IncompatiblePlugin;\n}', + }, + { + name: 'PluginVersionList', + declaration: 'export type PluginVersionList = {\n readonly status: \'listed\';\n readonly installed: string;\n readonly registry: Registry;\n readonly attempts: readonly RegistryAttempt[];\n readonly versions: readonly PluginVersion[];\n readonly omitted: number;\n} | {\n readonly status: \'refused\';\n readonly problem: \'not-updatable\' | \'not-found\' | \'network\' | \'unknown\';\n readonly reason: string;\n readonly attempts: readonly RegistryAttempt[];\n};', + }, { name: 'PostToolDecision', declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', @@ -6615,7 +6794,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType>;\n view(state: NoInfer): SessionProjectionViewValue;\n summary?: boolean;\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType>;\n view(state: NoInfer): SessionProjectionViewValue;\n summary?: boolean;\n } : never;\n stateVersion: number;\n}', + }, + { + name: 'ProjectionHandle', + declaration: 'export interface ProjectionHandle {\n stateOf(session: Session): S;\n dispose(): Promise;\n}', }, { name: 'ProjectionKeySelection', @@ -6753,6 +6936,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Registry', declaration: 'export type Registry = string | null;', }, + { + name: 'RegistryAttempt', + declaration: 'export interface RegistryAttempt {\n readonly registry: Registry;\n readonly problem?: \'not-found\' | \'network\' | \'unknown\';\n readonly reason?: string;\n}', + }, { name: 'Reload', declaration: 'export interface Reload {\n filename: string;\n modules: ReloadModules;\n runtime?: Plugin.Runtime | undefined;\n}', @@ -7421,10 +7608,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionsRequest', declaration: 'export interface SessionProjectionsRequest {\n readonly sessionId: SessionId;\n}', }, - { - name: 'SessionProjectionStateMap', - declaration: 'export interface SessionProjectionStateMap {\n}', - }, { name: 'SessionProjectionSummaryExcluded', declaration: 'export type SessionProjectionSummaryExcluded = T & {\n readonly [sessionProjectionSummaryExcluded]: true;\n};', @@ -8461,6 +8644,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TranslationSpec', declaration: 'export type TranslationSpec = AnonymousTranslationSpec | PaidTranslationSpec;', }, + { + name: 'TurnBoundaryProjection', + declaration: 'export interface TurnBoundaryProjection {\n readonly openTurnStartSeq: OptionalSessionSeq;\n readonly lastStepStartSeq: OptionalSessionSeq;\n readonly lastStepBoundary: {\n readonly kind: \'start\' | \'end\';\n readonly seq: SessionSeq;\n } | null;\n readonly lastTurn: number;\n}', + }, { name: 'TurnCompleteInput', declaration: 'export interface TurnCompleteInput {\n turnId: string;\n answer: string;\n durationMs: number;\n isAborted: boolean;\n reason: \'answer\' | \'aborted\' | \'error\';\n agentId?: string;\n usage?: TurnUsage;\n}', @@ -8645,6 +8832,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'UiRenderResult', declaration: 'export type UiRenderResult = UiNode;', }, + { + name: 'UpdateBundleOptions', + declaration: 'export interface UpdateBundleOptions {\n readonly registry?: Registry;\n readonly requestId?: PluginInstallRequestId;\n}', + }, { name: 'UpdateTeamTaskRequest', declaration: 'export interface UpdateTeamTaskRequest {\n readonly taskId: TeamTaskId;\n readonly expectedRevision: number;\n readonly action: TeamTaskAction;\n readonly subject?: string;\n readonly description?: string;\n readonly blockedBy?: readonly TeamTaskId[];\n readonly writeScopes?: readonly string[];\n readonly owner?: string;\n}', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 4e5401c09e..f218d8ca9b 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -53,6 +54,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } async function harness(): Promise { const ctx = new Context() await ctx.plugin(CommandRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) const plugin = await ctx.plugin(commandFeedback) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 273657e579..4508e8fa39 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -7,6 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -57,6 +58,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-commands'", "- name: '@deepseek-ai/dsh-command-feedback'", @@ -69,6 +71,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { context.loader.builtins.include = Include const modules = new Map([ ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-commands', CommandRuntime], ['@deepseek-ai/dsh-command-feedback', CommandFeedback], diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 2ea469c3f8..6f054541f1 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,3 +1,4 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' /** * Consumer API tests over a fake provider and the real policy collaborator: schemas, * validation, formatting, typed errors, intent dispatch, and observation-driven authorization. @@ -8,7 +9,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolResult } from '@deepseek-ai/dsh-tools' @@ -863,7 +863,7 @@ describe('sandbox escalation API (write/edit)', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 74b8ce5dd6..710939691f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -76,13 +76,11 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) if (options.sandboxMode === undefined) { await ctx.plugin(LocalFileSystem, { cwd: root }) } else { - // SandboxPolicy declares the registry as a required injection; mount it - // before the policy activates. - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) await ctx.plugin(SandboxedFileSystem, { cwd: root }) } @@ -616,6 +614,7 @@ describe('tool-str-replace-editor', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' }) diff --git a/packages/goal/goal/README.i18n.yaml b/packages/goal/goal/README.i18n.yaml index d7e3878d2c..4aa0d5c88d 100644 --- a/packages/goal/goal/README.i18n.yaml +++ b/packages/goal/goal/README.i18n.yaml @@ -24,8 +24,8 @@ en: 81d959eecdec4bbd zh: ff47fb2cba8f1e73 /deepseek-ai-dsh-goal/use-this-package/session-projection: - en: 251f84f8959a2072 - zh: c57a4d252e12b128 + en: 67b924c4f6902dc1 + zh: 99ed177a06126895 /deepseek-ai-dsh-goal/use-this-package/drive-the-lifecycle: en: 622890ced87a44e9 zh: 6ba1f3f872400452 diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index c39bc0cbcb..9e0a1ee60d 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -49,7 +49,7 @@ Load the package with a composition entry; the only deployment choice is the def ### Session projection -`GoalService` requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `goal` projection unit at startup; a composition that omits the projection registry cannot activate `ctx.goals`. The unit's version 6 host state retains the latest valid current goal, every previously used goal id, and the first strict replay failure. Its client view exposes the current goal or `null` before the first create and after a clear tombstone. The key merges into both `SessionProjectionStateMap` and `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame. +`GoalService` requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `goal` projection unit at startup; a composition that omits the projection registry cannot activate `ctx.goals`. The unit's version 6 host state retains the latest valid current goal, every previously used goal id, and the first strict replay failure. Its client view exposes the current goal or `null` before the first create and after a clear tombstone. The client key merges into `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame. ### Drive the lifecycle diff --git a/packages/goal/goal/README.zh.md b/packages/goal/goal/README.zh.md index a52fbcfdc2..ac8fd3837b 100644 --- a/packages/goal/goal/README.zh.md +++ b/packages/goal/goal/README.zh.md @@ -49,7 +49,7 @@ goal 适合一个需要跨自动 Goal Round 持续的长期完成目标——例 ### 会话投影 -`GoalService` 要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md)),并在启动时注册 `goal` 投影单元;未组合投影注册表的组合无法激活 `ctx.goals`。该单元版本为 6,其宿主状态保留最新的有效当前 goal、所有曾使用的 goal id,以及第一次严格回放失败。客户端视图提供当前 goal;首次 create 前与 clear tombstone 后为 `null`。该键同时合并到 `SessionProjectionStateMap` 与 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供客户端值。 +`GoalService` 要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md)),并在启动时注册 `goal` 投影单元;未组合投影注册表的组合无法激活 `ctx.goals`。该单元版本为 6,其宿主状态保留最新的有效当前 goal、所有曾使用的 goal id,以及第一次严格回放失败。客户端视图提供当前 goal;首次 create 前与 clear tombstone 后为 `null`。客户端键合并到 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供客户端值。 ### 驱动生命周期 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 48a02e485c..3d54ed75d6 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,7 +5,7 @@ */ import { randomUUID } from 'node:crypto' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' @@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionLogOffset } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { applyGoalEvent, @@ -47,10 +47,6 @@ import type { GoalSnapshotChangeMeta, } from './domain.ts' -// The pure payload outlet (./types.ts, ONE home of the `goal` projection-key -// declaration) re-exported onto the package root keeps the module edge in -// the emitted index.d.ts, so aggregate programs consuming the declarations -// still receive the SessionProjectionStateMap merge. export type * from './types.ts' export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' @@ -247,15 +243,21 @@ export class GoalService extends TypertRemoteService { private readonly resolved: ResolvedConfig private readonly runtimeStates = new WeakMap() + private goalProjectionHandle!: ProjectionHandle + constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), } + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + this.goalProjectionHandle = await ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('agent/created', ({ agent }) => { this.setActivation(agent.session, 'disarmed') }) - ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('session/event', (session, event) => { if (event.type !== 'goal/change') return const runtime = this.runtimeState(session) @@ -474,8 +476,7 @@ export class GoalService extends TypertRemoteService { /** Read the current durable projection maintained by the registry. */ private state(session: Session): GoalProjection | null { - const state = this.ctx.sessionProjections.stateOf(session, 'goal') - if (state === undefined) throw new Error('goal projection is not registered') + const state = this.goalProjectionHandle.stateOf(session) if (state.failure !== null) throw new Error(state.failure) return state.current } @@ -497,9 +498,7 @@ export class GoalService extends TypertRemoteService { const runtime = this.runtimeState(session) if (runtime.activation === activation) return runtime.activation = activation - const state = this.ctx.sessionProjections.stateOf(session, 'goal') - /* v8 ignore next -- static inject requires the projection registry before this service activates. */ - if (state === undefined) return + const state = this.goalProjectionHandle.stateOf(session) if (state.failure !== null) return const goal = this.view(state.current, runtime) this.ctx.emit('goal/activation-changed', { diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index eb7cd602ed..f7078de673 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -126,9 +126,6 @@ export interface GoalProjectionState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - goal: GoalProjectionState - } interface SessionProjectionMap { /** * The session's current goal and admitted-round count, or diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 5bcdcf1fcb..8948fe916b 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,3 +1,4 @@ +import { Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' @@ -171,12 +172,15 @@ describe('GoalService creation and replay', () => { it('also resolves the default when constructed directly without Cordis config normalization', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const stub = stubAgent('goal-direct-construction') await ctx.agents.register(stub.agent) - const goals = new GoalService(ctx) - await new Promise(resolve => setImmediate(resolve)) + class DirectGoalService extends GoalService { + initialize(): Promise { return this[Service.init]() } + } + const goals = new DirectGoalService(ctx) + await goals.initialize() expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({ objective: 'direct', maxGoalRounds: 256, }) @@ -184,8 +188,8 @@ describe('GoalService creation and replay', () => { it('declares no resource readers on goals/get, leaving its Session resource to the Agent lookup', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const goals = new GoalService(ctx) const marker = remoteMethods(goals).find(method => method.method === 'get') expect(marker).toMatchObject({ method: 'get' }) @@ -194,8 +198,8 @@ describe('GoalService creation and replay', () => { it('rejects invalid direct configuration', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({ code: 'GOAL_INVALID_MAX_ROUNDS', })) @@ -208,8 +212,8 @@ describe('GoalService creation and replay', () => { appendRound(first.session, created, 2) const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) await ctx.agents.register(resumed.agent) @@ -278,8 +282,8 @@ describe('GoalService creation and replay', () => { it('removes the service and projection with the providing fiber', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(GoalService) const first = ctx.goals const stub = stubAgent('goal-hmr') @@ -288,8 +292,8 @@ describe('GoalService creation and replay', () => { await fiber.dispose() expect(ctx.get('goals')).toBeUndefined() - expect(ctx.sessionProjections.stateOf(stub.session, 'goal')).toBeUndefined() - expect(() => first.get(stub.agent)).toThrow('goal projection is not registered') + expect(ctx.sessionProjections.checkpoint(stub.session).goal?.val).toBeUndefined() + expect(first.get(stub.agent)).toMatchObject({ id: goal.id }) await ctx.plugin(GoalService) expect(ctx.goals).not.toBe(first) @@ -506,8 +510,8 @@ describe('GoalService mutations', () => { it('does not delegate goal persistence to agent injection', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const stub = stubAgent('goal-independent-injection') stub.agent.inject = () => { throw new Error('injection must not be called') } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 227da38ad8..f71559bb8e 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -60,8 +60,8 @@ async function liveAgent(ctx: Context, session: Session): Promise { async function harness(withGoal: boolean): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withGoal) await ctx.plugin(GoalService) const session = ctx.sessions.create() const agent = await liveAgent(ctx, session) @@ -235,9 +235,10 @@ describe('goal projection unit', () => { const bench = await harness(true) bench.ctx.goals.create(bench.agent, { objective: 'poisoned replay' }) const failure = 'goal replay failed at session event 0: invalid restored goal stream' - const state = bench.ctx.sessionProjections.stateOf(bench.session, 'goal') + const handle = await bench.ctx.sessionProjections.register(goalProjectionDefinition) + const state = handle.stateOf(bench.session) expect(state).toBeDefined() - Object.assign(state!, { failure }) + Object.assign(state, { failure }) await expect(agentEvents(bench.ctx, bench.agent).serial('agent/created', { source: 'resume' })) .resolves.toBeUndefined() diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 492ae8374e..ee11ff3200 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4e9fdb5a89f4baa3 zh: 50d182467ae1f3a7 /deepseek-ai-dsh-tool-goal/understand-the-implementation/design: - en: a30f36299c8107f7 - zh: 65e6e1e19aeb5281 + en: 1c51463a2abd9514 + zh: b02fbbd125d96bf6 /deepseek-ai-dsh-tool-goal/understand-the-implementation/source-map: en: 4d5c648787409868 zh: b4938bfe660f18aa diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 158446e156..adb65e4f48 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -68,7 +68,7 @@ This section explains how the tools enforce authority and render output; the obs ### Design -- **Authority at execution.** Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. +- **Authority at execution.** The plugin requires `agents` and reads turn state through `turnBoundaryOf(session)`. Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. - **Host attestation of human input.** `{ kind: 'user' }` is assigned by `Agent.followup()` and `steer()` when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. - **System-prompt guidance with the configured threshold.** The package registers one `tool:goal` system-prompt section whose fixed text interpolates `blockedAfterConsecutiveRounds`; the same value is the hard lower bound enforced at execution. - **Wrap-up context for terminal rounds.** A successful autonomous `complete` or `blocked` defers a closing `` or `` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context. diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 1ec0a8e1f8..6f3da635bd 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### 设计 -- **执行时权限。** 每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 +- **执行时权限。** 插件依赖 `agents`,并通过 `turnBoundaryOf(session)` 读取轮次状态。每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 - **人类输入的宿主证明。** `Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配 `{ kind: 'user' }`,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承人类权限。 - **带配置阈值的系统提示词指引。** 本包注册一个 `tool:goal` 系统提示词章节,其固定文本插入 `blockedAfterConsecutiveRounds`;同一数值就是执行时强制执行的硬下限。 - **终局 Round 的结束上下文。** 成功的自主 `complete` 或 `blocked` 会延后一条 `` 或 `` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index d5349cd21d..4082aa08bc 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -35,8 +35,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index eadc0bd8f9..77568e3746 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -6,7 +6,6 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' /** The calling agent plus the immutable event cut and open-turn start seq used for authority checks. */ export interface GoalToolExecution { @@ -32,8 +31,8 @@ function openTurnEvents( ): Pick { // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() - const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') - if (boundary === undefined || boundary.openTurnStartSeq === null) { + const boundary = ctx.agents.turnBoundaryOf(agent.session) + if (boundary.openTurnStartSeq === null) { reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') } return { events, openTurnStartSeq: boundary.openTurnStartSeq } diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9f47f1a77d..482d614160 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -26,7 +26,7 @@ import { import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' -export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] +export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] /** Model policy and hard lower bounds for goal-state updates. */ export interface Config { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ecc7822bce..233ed1526f 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -3,7 +3,6 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' @@ -104,7 +103,6 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(GoalService) const fiber = await ctx.plugin(toolGoal, config) const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) @@ -195,20 +193,20 @@ describe('goal tool registration and presentation', () => { it('has the Loader-safe namespace export shape', () => { expect('default' in toolGoal).toBe(false) expect(toolGoal.name).toBe('tool-goal') - expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections']) + expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt']) const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(toolGoal)).toBe(toolGoal) }) it('fails invalid direct config before registering anything', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - expect(() => { - toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) - }).toThrow( + expect(() => { toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) }).toThrow( 'blockedAfterConsecutiveRounds must be a positive safe integer', ) expect(ctx.tools.get('get_goal')).toBeUndefined() @@ -216,6 +214,8 @@ describe('goal tool registration and presentation', () => { it('resolves the direct-apply default before registration', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) @@ -331,6 +331,26 @@ describe('goal tool execution authority', () => { expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) + it('does not carry human authority into a later non-human turn', async () => { + const { ctx, root } = await harness() + const turn = openTurn(root, { kind: 'user' }) + await execute(ctx, 'get_goal', {}, root.agent) + closeTurn(root, turn) + openTurn(root, { kind: 'test' }) + const result = await execute(ctx, 'create_goal', { objective: 'unauthorized' }, root.agent) + expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(ctx.goals.get(root.agent)).toBeUndefined() + }) + + it('reconstructs current-turn authority when the tools mount after input was committed', async () => { + const { ctx, root, fiber } = await harness() + await fiber.dispose() + openTurn(root, { kind: 'user' }) + await ctx.plugin(toolGoal, {}) + const result = await execute(ctx, 'create_goal', { objective: 'accepted before tool load' }, root.agent) + expect(resultGoal(result)).toMatchObject({ objective: 'accepted before tool load' }) + }) + it('accepts direct human steering in a goal-sourced root turn', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json index fc97dd09aa..d5c34d4e29 100644 --- a/packages/goal/tool-goal/tsconfig.json +++ b/packages/goal/tool-goal/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../goal" - }, - { - "path": "../../session/session-projection" } ] } diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 949ada7106..5ba3626c3f 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -39,14 +39,14 @@ en: b01849a5fa73aae9 zh: cbd7fcf50117502b /deepseek-ai-dsh-permission-presets/understand-the-implementation/read-side-and-custom: - en: c9ca792038f44654 - zh: 83cc2641c800c114 + en: 76deeb5fbcdf7525 + zh: e96ad7e107947c23 /deepseek-ai-dsh-permission-presets/understand-the-implementation/session-pinning-and-blank-reuse: - en: 893cea88bce22903 - zh: 2431e19ced670511 + en: 048f156ac376668a + zh: 62399824edc1cda6 /deepseek-ai-dsh-permission-presets/understand-the-implementation/catalog-projection-and-optional-command: - en: 2e79bec74274b054 - zh: 256526ab1b4b55e9 + en: 341f80202edd66a3 + zh: bd8ebb9d011b547f /deepseek-ai-dsh-permission-presets/further-exploration: en: ca27c3e438cffdc5 zh: ae5d25ae55e5d503 diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index e21a41a053..5b8e78077c 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -86,17 +86,17 @@ The observable behavior is covered in [Use this package](#use-this-package); thi ### Read side and `custom` -`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. A missing projection key fails explicitly. +`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. The service acquires its state reader during activation. `optionOf(name)` returns a configured entry, the live Auto entry, or the display-only `custom` entry. It throws only when the name matches none of those; a withdrawn Auto entry is unavailable. ### Session pinning and blank reuse -Mounting pins every live and future session: a genuinely fresh session gains the configured default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. The projection-owned seed marker makes this decision from the same incremental state as the knob values. A stored `auto` identity fails publication when the Auto integration is absent or rejects admission; the service neither rewrites it nor silently derives Full access. +Mounting pins every live and future session: a genuinely fresh session gains the configured default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. The projection-owned seed marker makes this decision from the same incremental state as the knob values. The synchronous creation listener is installed before projection registration starts. While its reader is not ready, Session creation fails and Agent creation rolls back; callers must await plugin activation before creating or restoring sessions. A stored `auto` identity fails publication when the Auto integration is absent or rejects admission; the service neither rewrites it nor silently derives Full access. ### Catalog, projection, and optional command -The service requires `ctx.sessionProjections` and registers a `permissions` projection containing only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection fail explicitly when the projection key is absent. +The service requires `ctx.sessionProjections` and awaits registration of `permissions`; its client view contains only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection use the retained projection handle. diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 0477b4197e..73e83dd68a 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -86,17 +86,17 @@ kind: "package-reference" ### 读取侧与 `custom` -`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。投影 key 缺失时会显式失败。 +`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。该服务在激活期间取得状态读取句柄。 `optionOf(name)` 返回配置条目、存活的 Auto 条目或仅供显示的 `custom` 条目。只有名称不匹配这三者时才抛错;已撤回的 Auto 条目不可用。 ### 会话固定与空白复用 -挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。 +挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。同步创建监听器在投影注册开始前安装。读取句柄尚未就绪时,Session 创建失败,Agent 创建回滚;调用方必须等待插件激活完成后再创建或恢复会话。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。 ### 目录、投影与可选命令 -该服务要求 `ctx.sessionProjections`,并注册一个只含 `currentValue` 的 `permissions` 投影。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用会在投影 key 缺失时显式失败。 +该服务要求 `ctx.sessionProjections`,并等待 `permissions` 注册完成;其客户端视图只含 `currentValue`。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用使用保留的投影句柄。 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 8135d74282..de98208579 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -15,7 +15,7 @@ import type {} from '@deepseek-ai/dsh-settings' import type { Volatile } from '@deepseek-ai/cordis' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' @@ -29,7 +29,7 @@ import type {} from '@deepseek-ai/dsh-shell' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' // Type-only: resolves the required projection service and optional settings/command children. -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' import type { PermissionCatalog, PermissionSelection, PresetOption } from './types.ts' @@ -41,13 +41,6 @@ declare module '@deepseek-ai/cordis' { } } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Latest logged permission overrides and constructor-seed status. */ - permissions: PermissionProjectionState - } -} - declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -204,6 +197,8 @@ export class PermissionPresetService extends TypertRemoteService { private autoAdmit: (() => void) | undefined private defaultSettings: () => PermissionSettings + private permissionProjectionHandle: ProjectionHandle | undefined + constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') @@ -230,11 +225,17 @@ export class PermissionPresetService extends TypertRemoteService { if (!Object.hasOwn(this.presets, defaultPreset)) throw new Error(`permission: unknown default preset "${defaultPreset}"`) return { defaultPreset } } + } + protected async [Service.init](): Promise { + const ctx = this.ctx + ctx.on('session/created', (session) => { + this.pinInitialPermission(session) + }) const selectionSchema = zod.object({ currentValue: zod.string().min(1), }) as zod.ZodType - ctx.sessionProjections.register({ + this.permissionProjectionHandle = await ctx.sessionProjections.register({ key: 'permissions', stateVersion: 2, stateSchema: permissionStateSchema, @@ -242,9 +243,6 @@ export class PermissionPresetService extends TypertRemoteService { apply: applyPermissionEvent, wire: { viewSchema: selectionSchema, view: state => ({ currentValue: this.derive(state) }) }, }) - ctx.on('session/created', (session) => { - this.pinInitialPermission(session) - }) for (const session of ctx.sessions.list()) { this.pinInitialPermission(session) } @@ -326,9 +324,10 @@ export class PermissionPresetService extends TypertRemoteService { } private permissionState(session: Session): PermissionProjectionState { - const state = this.ctx.sessionProjections.stateOf(session, 'permissions') - if (state === undefined) throw new Error('permission: permissions session projection is not registered') - return state + if (this.permissionProjectionHandle === undefined) { + throw new Error('permission: presets are not ready; await plugin activation before creating or restoring a session') + } + return this.permissionProjectionHandle.stateOf(session) } /** @@ -423,7 +422,8 @@ export class PermissionPresetService extends TypertRemoteService { * initialized sessions preserve their effective knob values and only gain * the missing durable facts. A stored Auto identity requires its live * integration and passes its admission check before - * publication. + * publication. Creation while the permission reader is still registering + * fails synchronously so the publishing caller can roll back. */ private pinInitialPermission(session: Session): void { const state = this.permissionState(session) diff --git a/packages/interaction/permission-presets/tests/initialization.spec.ts b/packages/interaction/permission-presets/tests/initialization.spec.ts new file mode 100644 index 0000000000..82a41f49ff --- /dev/null +++ b/packages/interaction/permission-presets/tests/initialization.spec.ts @@ -0,0 +1,102 @@ +/** Permission admission must finish before a Session or Agent is published. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import PermissionPresetService, { AUTO_PRESET } from '@deepseek-ai/dsh-permission-presets' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +async function setup() { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(ApprovalService, { policy: 'ask' }) + ctx.provide('shell', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission admission tests do not execute shell commands') }, + run() { throw new Error('permission admission tests do not execute shell commands') }, + start() { throw new Error('permission admission tests do not execute shell commands') }, + }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'permissions') { + entered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(PermissionPresetService) + await entered.promise + return { ctx, fiber, release } +} + +function autoSeed() { + const source = Session.create(SessionId('auto-source')) + source.append('permission/preset', { preset: AUTO_PRESET }) + source.append('sandbox/mode', { mode: 'danger-full-access' }) + source.append('approval/policy', { policy: 'never' }) + return source.snapshotEvents() +} + +describe('permission admission during activation', () => { + it('rejects synchronous Session creation until the permission reader is ready', async () => { + const { ctx, fiber, release } = await setup() + const id = SessionId('pending-session') + expect(() => ctx.sessions.create(id)).toThrow(/permission:.*not ready/) + expect(ctx.sessions.get(id)).toBeUndefined() + + release.resolve(undefined) + await fiber + const session = ctx.sessions.create(id) + expect(ctx.permissionPresets.current(session)).toBe('workspace-write') + expect(ctx.approval.overrideOf(session)).toBe('ask') + expect(session.snapshotEvents().map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it.each(['fresh', 'auto'] as const)('rolls back %s Agent creation before publication while registration is pending', async (kind) => { + const { ctx, fiber, release } = await setup() + const id = SessionId('pending-agent') + const options = { sessionId: id, ...kind === 'auto' ? { seed: autoSeed() } : {} } + const published = vi.fn() + ctx.on('agent/created', published) + + await expect(ctx.agents.create(options).then(() => 'published')).rejects.toThrow(/permission:.*not ready/) + expect(published).not.toHaveBeenCalled() + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + + release.resolve(undefined) + await fiber + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + + if (kind === 'auto') { + await expect(ctx.agents.create(options)).rejects.toThrow(/cannot restore preset "auto"/) + expect(published).not.toHaveBeenCalled() + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + const admit = vi.fn() + ctx.permissionPresets.registerAuto(admit) + const { agent } = await ctx.agents.create(options) + expect(admit).toHaveBeenCalledTimes(1) + expect(ctx.permissionPresets.current(agent.session)).toBe(AUTO_PRESET) + expect(ctx.approval.overrideOf(agent.session)).toBe('never') + } else { + const { agent } = await ctx.agents.create(options) + expect(ctx.permissionPresets.current(agent.session)).toBe('workspace-write') + expect(ctx.approval.overrideOf(agent.session)).toBe('ask') + } + expect(published).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 10de7a4f31..6641d2c259 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, @@ -65,17 +65,17 @@ describe('permission preset fold', () => { it('folds the latest preset selection and steps over unrelated events', async () => { const ctx = await mounted() const session = freshSession('sess-fold') - const presetOf = () => ctx.sessionProjections.stateOf(session, 'permissions')?.preset ?? null - expect(presetOf()).toBeNull() + const presetOf = () => ctx.sessionProjections.checkpoint(session).permissions?.val + expect(presetOf()).toMatchObject({ preset: null }) session.append('permission/preset', { preset: 'danger-full-access' }) session.append('permission/preset', { preset: 'workspace-write' }) - expect(presetOf()).toBe('workspace-write') + expect(presetOf()).toMatchObject({ preset: 'workspace-write' }) // The knob fold steps over non-preset events to the latest selection. session.append('sandbox/mode', { mode: 'read-only' }) - expect(presetOf()).toBe('workspace-write') + expect(presetOf()).toMatchObject({ preset: 'workspace-write' }) const seeded = Session.create(SessionId('sess-fold-seeded'), []) - expect(ctx.sessionProjections.stateOf(seeded, 'permissions')?.seeded).toBe(true) + expect(ctx.sessionProjections.checkpoint(seeded).permissions?.val).toMatchObject({ seeded: true }) }) }) @@ -85,13 +85,6 @@ describe('PermissionPresetService', () => { expect(ctx.get('permissionPresets')).toBeUndefined() }) - it('fails when the permissions projection key is absent', async () => { - const ctx = await mounted() - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - expect(() => ctx.permissionPresets.current(freshSession('missing-permission-projection-key'))) - .toThrow('permission: permissions session projection is not registered') - }) - it('advertises the preset table in declaration order and resolves bundles', async () => { const ctx = await mounted() expect(ctx.permissionPresets.names).toEqual(['workspace-write', 'danger-full-access']) diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 5ba9c7f544..7218538e69 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createToolResultMessage, ToolCallId, type ToolSchema } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Session, SessionId, SessionSeq, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -69,6 +70,7 @@ interface OptionSchemaShape { async function setup(config: toolAskUser.Config = { mode: 'timed', timeout: -1 }) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 06cb67b8b6..846a8a65be 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -7,16 +7,16 @@ * @module @deepseek-ai/dsh-user-questions */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError, type MessageId, type ToolCallId, type UserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import z from '@deepseek-ai/schemastery' -import { userQuestionProjectionDefinition } from './projection.ts' +import { userQuestionProjectionDefinition, type UserQuestionProjectionState } from './projection.ts' import { TimedQuestionWait } from './timed-wait.ts' declare module '@deepseek-ai/cordis' { @@ -78,14 +78,12 @@ type QueuedReply = { messageId: MessageId; claimedTurn?: number } /** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */ export class UserQuestionService extends TypertRemoteService { static Config = z.object({}) + private userQuestionProjectionHandle: ProjectionHandle | undefined private readonly waits = new Map>() private readonly queuedReplies = new WeakMap>() constructor(ctx: Context) { super(ctx, 'userQuestions') - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(userQuestionProjectionDefinition) - }) ctx.effect(() => () => { for (const calls of this.waits.values()) { for (const wait of calls.values()) wait.close(abortedQuestion()) @@ -128,6 +126,14 @@ export class UserQuestionService extends TypertRemoteService { calls.delete(callId) } + protected async [Service.init](): Promise { + const binding = this.ctx.inject(['sessionProjections'], async (ctx) => { + this.userQuestionProjectionHandle = await ctx.sessionProjections.register(userQuestionProjectionDefinition) + return () => { this.userQuestionProjectionHandle = undefined } + }) + if (this.ctx.get('sessionProjections') !== undefined) await binding + } + private assertLiveRoot(agent: Agent): void { const agents = this.ctx.get('agents') if (agents === undefined || agents.get(agent.id) !== agent) { @@ -144,8 +150,7 @@ export class UserQuestionService extends TypertRemoteService { } private continued(agent: Agent): readonly PendingUserQuestion[] { - const state = this.ctx.get('sessionProjections')?.stateOf(agent.session, 'userQuestions') - return (state?.questions.active ?? []).filter(question => question.state === 'continued') + return this.userQuestionProjectionHandle?.stateOf(agent.session).questions.active.filter(question => question.state === 'continued') ?? [] } /** diff --git a/packages/interaction/user-questions/src/projection.ts b/packages/interaction/user-questions/src/projection.ts index 7453a265a5..0c64c93fa0 100644 --- a/packages/interaction/user-questions/src/projection.ts +++ b/packages/interaction/user-questions/src/projection.ts @@ -329,9 +329,3 @@ export const userQuestionProjectionDefinition = { wire: { viewSchema: projectionViewSchema, view: state => state.questions }, stateVersion: 2, } satisfies ProjectionDefinition<'userQuestions', UserQuestionProjectionState> - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - userQuestions: UserQuestionProjectionState - } -} diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index f0f744259a..1b03fa746a 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -198,6 +198,7 @@ describe('UserQuestionService', () => { it('rejects a live runtime-owned agent before reaching the provider', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } @@ -220,6 +221,7 @@ describe('UserQuestionService', () => { it('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = provider('yes') @@ -250,6 +252,7 @@ describe('UserQuestionService', () => { it('rejects a stale agent object that reuses a live id', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } @@ -389,8 +392,8 @@ function liveAgent(id: string): LiveAgent { async function timedContext(): Promise { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) return ctx } @@ -834,10 +837,12 @@ describe('late replies', () => { it('answers nothing when the composition carries no Session projections', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const agent = liveAgent('late-no-projections') - ctx.agents.enter(agent, undefined) + ctx.provide('agents', { + get: (id: SessionId) => id === agent.id ? agent : undefined, + roots: () => [agent], + } as never) + await ctx.plugin(UserQuestionService) askInLog(agent, timedCallId) continueInLog(agent, timedCallId) @@ -895,7 +900,7 @@ describe('late replies', () => { error: { name: 'SessionFormatError', code: TOOL_OUTCOME_UNKNOWN }, }, { surfaceOp: 'append' }) - expect(ctx.sessionProjections.stateOf(agent.session, 'userQuestions')?.questions).toEqual({ active: [], settled: [] }) + expect(ctx.sessionProjections.checkpoint(agent.session).userQuestions?.val).toMatchObject({ questions: { active: [], settled: [] } }) expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [{ id: 'scope', selected: [] }] })).toBe(false) expect(agent.steer).not.toHaveBeenCalled() await ctx.fiber.dispose() diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 538c956140..1457e8413b 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -5,6 +5,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' @@ -117,6 +118,7 @@ function producer(overrides: ProducerOverrides = {}) { async function harness(config: JobsConfig = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, config) ctx.jobs.attachController('test-controller') @@ -183,6 +185,7 @@ describe('LocalJobRegistry.start', () => { it('refuses an owner whose own composition attaches no controller', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) // Two standing preset mounts over one registry; only the first loads the @@ -204,6 +207,7 @@ describe('LocalJobRegistry.start', () => { it('lets a controller attached without a scope serve every owner', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) // The host-plane composition's own controls: no scope, so the global layer @@ -911,6 +915,7 @@ describe('LocalJobRegistry owner cleanup', () => { it('registers owner cleanup on the agent scope rather than the jobs fiber', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') @@ -977,6 +982,7 @@ describe('LocalJobRegistry owner cleanup', () => { describe('LocalJobRegistry disposal', () => { it('cancels live jobs, awaits settlement, and announces the teardown to outside subscribers', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(LocalJobRegistry) await ctx.plugin(Object.assign((inner: Context) => { @@ -1007,6 +1013,7 @@ describe('LocalJobRegistry disposal', () => { it('force-fails a throwing cancel so service disposal does not await producer done', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') @@ -1044,6 +1051,7 @@ describe('LocalJobRegistry disposal', () => { it('detaches owner effects from still-live agent scopes when the service unloads', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') @@ -1069,6 +1077,7 @@ describe('LocalJobRegistry disposal', () => { it('drops a scoped layer when its registrations dispose', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) const standing = createScope(ctx, {}) diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 0e69eca22c..d2e9daa981 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -4,6 +4,7 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -45,6 +46,7 @@ async function setup(config: ToolJobs.Config = {}, registry: RegistryConfig = {} const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) const agentsFiber = await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, registry) const toolsFiber = await ctx.plugin(ToolJobs, config) @@ -182,6 +184,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await expect(ctx.plugin(ToolJobs, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 })) @@ -201,6 +204,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) try { @@ -299,6 +303,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) ToolJobs.apply(ctx, {}) @@ -704,6 +709,7 @@ describe('completion notices across scoped mounts', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 698065e66e..6952c493e2 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, LoggerLevel } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalAttachments from '@deepseek-ai/dsh-attachment-local' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' @@ -148,6 +149,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () const unversionedModule = pathToFileURL(join(pluginRoot, 'plugin.mjs')).href const ctx = await boot() await ctx.plugin(Loader) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 9ad719a67d..5c401272fa 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -10,6 +10,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' @@ -60,6 +61,7 @@ async function loadComposition( " name: '@deepseek-ai/dsh-session'", '- id: agents', " name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-session-projection'", '- id: deepseek-llm-api-extensions', " name: '@deepseek-ai/dsh-deepseek-llm-api-extensions'", '- id: session-log-deepseek', @@ -94,6 +96,7 @@ async function loadComposition( ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-deepseek-llm-api-extensions', DeepSeekLlmApiExtensionRegistry], ['@deepseek-ai/dsh-session-log-deepseek', SessionLogDeepSeek], ['@deepseek-ai/dsh-plugin-package-inventory-deepseek', DeepSeekPluginPackageInventory], diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index 4738fe9b6a..6321512705 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage, createSystemMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' @@ -228,6 +229,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () const ctx = new Context() contexts.push(ctx) await ctx.plugin(Loader) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1a5caa57c6..636808aaba 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -21,8 +21,8 @@ en: f3b761c7c7ecb82f zh: e10d999aa348ef86 /deepseek-ai-dsh-llm-pi-ai/use-this-package/configure-provider-routes: - en: c5d28300fa2cfc6f - zh: c162855dcb54b65b + en: 738edb4ae053740d + zh: cc6853e2bdac01ec /deepseek-ai-dsh-llm-pi-ai/use-this-package/sign-in-to-a-provider: en: c36f66438638ae08 zh: 066a2fc4cb97c86f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index c30a414465..2b96d6ac22 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -81,8 +81,8 @@ Each profile may set a `retryPolicy`; omission uses normal mode with five retrie | `models` | installed catalog | Replaces the route's catalog wholesale; each entry defaults from the installed model | | `modelOverrides` | none | Reshapes individual installed-catalog models without replacing the rest | | `compat` | catalog detection | Wire-compatibility switches for unrecognized endpoints | -| `defaultContextWindow` | `262,144` | Capacity fallback for undescribed models | -| `defaultMaxTokens` | `32,768` | Output-cap fallback for undescribed models | +| `defaultContextWindow` | `1,000,000` | Capacity fallback for undescribed models | +| `defaultMaxTokens` | `128,000` | Output-cap fallback for undescribed models | | `requestImagePixelBudget` | `4,194,304` | Total-pixel budget for each deterministic request image | | `requestImageMaxBytes` | `1 MiB` | Encoded-byte target for each request image before base64 expansion | | `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound; a request whose retained images exceed it fails with `IMAGE_OFFLOAD_REQUIRED` | diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0d7d5c22fe..db45c65a73 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -81,8 +81,8 @@ kind: "package-reference" | `models` | 已安装目录 | 整体替换路由目录;每个条目从已安装模型取默认值 | | `modelOverrides` | 无 | 重塑个别已安装目录模型,而不替换其余模型 | | `compat` | 目录检测 | 无法识别端点的协议兼容开关 | -| `defaultContextWindow` | `262,144` | 未描述模型的容量回退 | -| `defaultMaxTokens` | `32,768` | 未描述模型的输出上限回退 | +| `defaultContextWindow` | `1,000,000` | 未描述模型的容量回退 | +| `defaultMaxTokens` | `128,000` | 未描述模型的输出上限回退 | | `requestImagePixelBudget` | `4,194,304` | 每张确定性请求图片的总像素预算 | | `requestImageMaxBytes` | `1 MiB` | 每张请求图片在 base64 扩展前的编码字节目标 | | `maxRequestImageBytes` | `20 MiB` | base64 图片载荷总上限,保留图片超过时请求以 `IMAGE_OFFLOAD_REQUIRED` 失败 | diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b07cd57bfb..3731e1bcc0 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -62,10 +62,10 @@ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048 export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Context capacity assumed for a model neither configuration nor the catalog sizes. */ -export const DEFAULT_CONTEXT_WINDOW = 262_144 +export const DEFAULT_CONTEXT_WINDOW = 1_000_000 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ -export const DEFAULT_MAX_TOKENS = 32_768 +export const DEFAULT_MAX_TOKENS = 128_000 /** * Modalities assumed for a model neither configuration nor the catalog @@ -127,13 +127,13 @@ export interface PiAiProviderProfile { compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor - * the installed catalog sizes (default 262,144). A guess by construction, so + * the installed catalog sizes (default 1,000,000). A guess by construction, so * a deployment whose gateway serves smaller models corrects it here. */ defaultContextWindow?: number /** * Output capability for a model this route lists that neither the entry nor - * the installed catalog sizes (default 32,768). This sizes the model; it + * the installed catalog sizes (default 128,000). This sizes the model; it * never becomes a per-request cap on its own. */ defaultMaxTokens?: number diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index e142027ae6..32bb8f3993 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -170,7 +170,7 @@ describe('hand-declared providers', () => { resolved.get(route)?.piProvider?.getModels() ?? [] expect(modelsOf('acme-gateway')).toMatchObject([ - { id: 'bare', contextWindow: 262_144, maxTokens: 32_768 }, + { id: 'bare', contextWindow: 1_000_000, maxTokens: 128_000 }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }, ]) // The fallback is a guess, so a deployment whose gateway serves smaller diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 6dbc01a710..691b098e1c 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -39,8 +39,8 @@ en: 9b79cdd6597930e0 zh: 0838f3efbc1e83ac /deepseek-ai-dsh-llm-retry/understand-the-implementation/recovery-flow: - en: 85bace285b029101 - zh: 6e0237450ca6ccc0 + en: 4ba0a3ef069bd416 + zh: e1cf971729a8717c /deepseek-ai-dsh-llm-retry/understand-the-implementation/waterfall-composition: en: 388e8db333e03cb1 zh: 54b6446ac3584724 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 2393ffbfea..3b1455864e 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -81,6 +81,8 @@ The executor is built on one rule: **durable before wait, open-step boundaries.* ### Recovery flow +The recovery listener is installed before awaiting projection registration. Failures arriving during activation wait for the reader before applying the provider policy. Registration failure propagates to the agent; cancellation or plugin disposal while waiting prevents retry records and further attempts. + A failed step arrives on the waterfall with its provider and resolved policy. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. ### Waterfall composition diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index eef7a17aae..bbad68655d 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -81,6 +81,8 @@ kind: "package-reference" ### 恢复流程 +恢复监听器在等待投影注册前安装。激活期间到达的失败会先等待读取句柄,再应用提供方策略。注册失败会传播给 agent;等待期间取消或释放插件,不会写入重试记录或发起后续尝试。 + 失败步骤连同其提供方与解析后的策略一起到达 waterfall。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 ### Waterfall 组合 diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 89ffc14c40..45fa11c5d4 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -95,12 +95,6 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise = zod.record(zod.string(), retry: zod.number().int().nonnegative(), retryId: zod.string(), })) as unknown as zod.ZodType -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Retry state for the current step by provider and policy. */ - llmRetry: LlmRetryState - } -} -export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void { +/** + * Install provider-routed normal or unbounded request recovery. + * Recovery admitted during activation awaits projection registration. A + * registration failure propagates; cancellation or disposal suppresses retries. + * @param ctx - plugin context that owns the listener and active waits. + * @param config - empty executor config; provider registrations own policy. + * @param internals - non-serializable deterministic hooks for tests. + * @returns when recovery listeners and their projection are ready. + */ +export async function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): Promise { validateConfig(config) - ctx.sessionProjections.register({ + const projectionReady = ctx.sessionProjections.register({ key: 'llmRetry', stateVersion: 1, stateSchema: llmRetryStateSchema, @@ -160,7 +157,6 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna signal: AbortSignal, ): Promise { const fusedSignal = AbortSignal.any([signal, lifetime.signal]) - if (fusedSignal.aborted) return const eventData: LlmRetryEventData = policy.mode === 'normal' ? { retryId, @@ -196,8 +192,9 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna next: () => Promise, ): Promise { if (policy === undefined) return next() + const llmRetryProjectionHandle = await projectionReady + if (signal.aborted || ctx.fiber.uid === null || lifetime.signal.aborted) return if (policy.mode === 'always') { - if (signal.aborted || lifetime.signal.aborted) return const fusedSignal = AbortSignal.any([signal, lifetime.signal]) // The loop and plugin lifetime stay open until delegated recovery settles. // An abort then wins before the decision or fallback can mutate later state. @@ -217,7 +214,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const policyKey = retryPolicyKey(policy) - const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry') as LlmRetryState + const retryState = llmRetryProjectionHandle.stateOf(agent.session) const previous = retryState[retryStateKey(provider, policyKey)] const previousRetry = previous?.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() @@ -256,4 +253,5 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna lifetime.abort(new Error('llm-retry plugin disposed')) await Promise.allSettled([...active]) }, 'llm-retry: abort and drain active recovery') + await projectionReady } diff --git a/packages/llm/llm-retry/tests/initialization.spec.ts b/packages/llm/llm-retry/tests/initialization.spec.ts new file mode 100644 index 0000000000..f8b88d3f9f --- /dev/null +++ b/packages/llm/llm-retry/tests/initialization.spec.ts @@ -0,0 +1,82 @@ +/** Provider recovery overlapping retry projection registration. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import * as retry from '@deepseek-ai/dsh-llm-retry' +import { SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +class RetryAdapter extends MockAdapter { + override providerRetryPolicy(): ResolvedRetryPolicy { + return resolveRetryPolicy({ + mode: 'normal', maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'initialization test') + } +} + +describe('retry activation', () => { + it.each(['ready', 'cancelled', 'disposed', 'failed'] as const)('handles a %s registration before settling the first provider failure', async (outcome) => { + const ctx = new Context() + const registered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new RetryAdapter([ + [{ type: 'finish', reason: { kind: 'error', failure: { code: 'SERVER', message: 'temporary failure' } } }], + textResponse('recovered'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = await ctx.agentLoop.create(SessionId('initial-retry'), { provider: 'mock', model: 'mock' }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'llmRetry') { + registered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(retry) + await registered.promise + const recovering = Promise.withResolvers() + ctx.on('agent/request-error', (_payload, next) => { + recovering.resolve(undefined) + return next() + }, { prepend: true }) + const errors: unknown[] = [] + ctx.on('agent/error', ({ error }) => { errors.push(error) }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover this request' }], source: { kind: 'user' } })) + await recovering.promise + + if (outcome === 'cancelled') agent.cancel({ kind: 'user' }) + const disposal = outcome === 'disposed' ? fiber.dispose() : undefined + if (outcome === 'failed') { + const rejected = expect(Promise.resolve(fiber)).rejects.toThrow('retry registration failed') + release.reject(new Error('retry registration failed')) + await rejected + } else { + release.resolve(undefined) + await (disposal ?? fiber) + } + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(outcome === 'ready' ? 2 : 1) + const log = agent.session.snapshotEvents() + expect(log.filter(event => event.type === 'llm/retry')).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(log.filter(event => event.type === 'llm/retry-started')).toHaveLength(outcome === 'ready' ? 1 : 0) + if (outcome === 'ready') { + expect(adapter.requests[1]?.messages).toEqual(adapter.requests[0]?.messages) + expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'recovered' }]) + } + if (outcome === 'failed') expect(errors).toContainEqual(new Error('retry registration failed')) + }) +}) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 3adb88de05..43ead12c94 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -20,6 +20,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import type { Config as retryConfig, RetryInternals as retryRetryInternals } from '../src/index.ts' import * as retry from '../src/index.ts' type ScriptEntry = Error | Iterable | AsyncIterable @@ -103,7 +104,7 @@ async function harness( adapter: ScriptedAdapter, policies: Readonly> = { mock: normalConfig() }, beforeRetry?: (ctx: Context) => void, - internals: retry.RetryInternals = {}, + internals: retryRetryInternals = {}, ): Promise<{ ctx: Context; retryFiber: Fiber; disposeAdapter: () => void }> { const ctx = new Context() await ctx.plugin(LlmRuntime) @@ -114,8 +115,8 @@ async function harness( await ctx.plugin(AgentRegistry) beforeRetry?.(ctx) adapter.configureRetryPolicies(policies) - const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { - retry.apply(inner, {}, internals) + const retryFiber = await ctx.plugin(Object.assign(async (inner: Context) => { + await retry.apply(inner, {}, internals) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) const disposeAdapter = ctx.llm.registerAdapter(['mock', 'other'], adapter) @@ -1047,23 +1048,21 @@ describe('provider-routed retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it('rejects retry policy configured on the executor instead of a provider', () => { - expectTypeOf<{}>().toExtend() - expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() + it('rejects retry policy configured on the executor instead of a provider', async () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() const ctx = new Context() - // `apply` registers its projection unit first; the registry is a required - // injection, so the direct-apply path must carry it too. new SessionProjectionRegistry(ctx) - expect(() => { - retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retry.Config) - }).toThrow(/retryPolicy belongs under each provider/) + await expect(async () => { + await retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retryConfig) + }).rejects.toThrow(/retryPolicy belongs under each provider/) }) - it('rejects unknown executor config', () => { + it('rejects unknown executor config', async () => { const ctx = new Context() new SessionProjectionRegistry(ctx) - expect(() => { - retry.apply(ctx, { retryPolciy: {} } as unknown as retry.Config) - }).toThrow(/unknown key "retryPolciy"/) + await expect(async () => { + await retry.apply(ctx, { retryPolciy: {} } as unknown as retryConfig) + }).rejects.toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index 28793ba945..17b4a558f8 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -48,8 +48,8 @@ async function harness( await ctx.plugin(Loader) if (packageService) await ctx.plugin(PluginPackages) ctx.loader.builtins.include = Include - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(AgentPresets, { default: 'fixture' }) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) const inventory = enabled === undefined @@ -260,6 +260,7 @@ describe('DeepSeek plugin package inventory', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(Loader) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) await ctx.plugin(PluginInventory) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts index ce1c4c96cb..3b8607402c 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts @@ -8,6 +8,7 @@ import { Context, FiberState } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as PluginInventory from '../src/index.ts' @@ -41,6 +42,7 @@ it.each([true, false])('reports an unversioned package by name alongside a versi const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ '- name: cordis:agents', + '- name: cordis:projections', '- name: cordis:deepseek-extensions', '- name: cordis:plugin-inventory', `- name: ${JSON.stringify(privateUrl)}`, @@ -54,6 +56,7 @@ it.each([true, false])('reports an unversioned package by name alongside a versi await ctx.plugin(Loader) ctx.loader.builtins.include = Include ctx.loader.builtins.agents = AgentRegistry + ctx.loader.builtins.projections = SessionProjectionRegistry ctx.loader.builtins['deepseek-extensions'] = DeepSeekLlmApiExtensionRegistry ctx.loader.builtins['plugin-inventory'] = PluginInventory await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 17a68fbe02..fe4b0d57b1 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -9,8 +9,8 @@ en: e51cc1114d193318 zh: f45cbb692bc8de69 /deepseek-ai-dsh-token-meter/summary: - en: 106e3eda9a85bdd1 - zh: 051bc764bf7a1912 + en: 7f5142b836b0338a + zh: 2735005241e2aae2 /deepseek-ai-dsh-token-meter/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -24,8 +24,8 @@ en: 58de3b364ab5af6b zh: c5440af43d321e56 /deepseek-ai-dsh-token-meter/use-this-package/session-projections: - en: 6720ae9c7a0602b2 - zh: 699a0b9927300aa3 + en: acd064f47010ced9 + zh: ef5a456695050242 /deepseek-ai-dsh-token-meter/use-this-package/composition: en: 1a0b7f49e01cba08 zh: 666213a1024767c5 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 51eee2f2ae..8730acfd18 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `ctx.tokenMeter` to estimate a session's current request and context pressure or price one message. Measurements replay the durable session log, remain deterministic, and make no model calls, so compaction, occupancy displays, and telemetry can share one result. When session projections are available, consumers can read `tokenUsage`, `contextPressure`, and `contextBreakdown`; text and routes without image pricing use an approximate fixed heuristic, declared visual-token pricing applies when available, and files are priced as model-visible handle text. Provider-reported usage is reused only for an identical request envelope; the package adds no model-visible content and makes no loop decisions. +Use `ctx.tokenMeter` to estimate a session's current request and context pressure or price one message. Measurements replay the durable session log, remain deterministic, and make no model calls, so compaction, occupancy displays, and telemetry can share one result. The service requires session projections; consumers can read `tokenUsage`, `contextPressure`, and `contextBreakdown`; text and routes without image pricing use an approximate fixed heuristic, declared visual-token pricing applies when available, and files are priced as model-visible handle text. Provider-reported usage is reused only for an identical request envelope; the package adds no model-visible content and makes no loop decisions. ## Table of Contents @@ -46,7 +46,7 @@ The measurement anchor includes the priced surface immediately before the succes ### Session projections -When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. +Token-meter registers three projection units through its required `ctx.sessionProjections` service. Host consumers call `ctx.tokenMeter.contextPressure(session)` to read the latest provider-reported prompt size and route capacity synchronously; the registration handle stays private. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. Image offload reprices existing node identities while preserving prior usage anchors. The fixed reference heuristic excludes `offloaded` metadata, so an offload decision does not change `contextBreakdown` or the scalar heuristic total; route-aware measurement replaces the selected visual prices with placeholder text. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 0dc929f492..e2a86c6528 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `ctx.tokenMeter` 估算会话当前的请求与上下文压力,或为单条消息计价。测量会回放持久会话日志,结果确定且不进行模型调用,因此压缩、占用显示与遥测可以共享同一结果。会话投影可用时,消费方可以读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown`;文本和没有图片定价的路由采用近似的固定启发式规则,存在声明时应用视觉 token 定价,文件则按模型可见的句柄文本计价。只有请求 envelope 完全相同时才复用提供方报告的用量;本包不添加模型可见内容,也不在 loop 中做决策。 +使用 `ctx.tokenMeter` 估算会话当前的请求与上下文压力,或为单条消息计价。测量会回放持久会话日志,结果确定且不进行模型调用,因此压缩、占用显示与遥测可以共享同一结果。该服务依赖会话投影;消费方可以读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown`;文本和没有图片定价的路由采用近似的固定启发式规则,存在声明时应用视觉 token 定价,文件则按模型可见的句柄文本计价。只有请求 envelope 完全相同时才复用提供方报告的用量;本包不添加模型可见内容,也不在 loop 中做决策。 ## 目录 @@ -46,7 +46,7 @@ const price = ctx.tokenMeter.estimateMessage(message) ### 会话投影 -当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 +token-meter 通过必需的 `ctx.sessionProjections` 服务注册三个投影单元。Host 消费方调用 `ctx.tokenMeter.contextPressure(session)` 同步读取提供方报告的最新提示词规模和路由容量;注册句柄保留在内部。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 图片省略重新计算现有节点的价格,同时保留此前的用量锚点。固定引用启发式规则不计入 `offloaded` 元数据,因此一次省略决定不改变 `contextBreakdown` 或标量启发式总量,按路由的测量则把所选图片的视觉价格换成占位文本价格。 diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 66ea6ae398..43ce1b04f7 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -13,15 +13,9 @@ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' // Type-only: the `compaction/*` SessionEventMap merges (shadow-price events). import type {} from '@deepseek-ai/dsh-compaction' import { estimateMessage, estimateToolsTokens } from './estimate.ts' -// Import for the `contextBreakdown` SessionProjectionStateMap key merge. +// Type-only: the client projection key and value declaration. import type {} from './projection.ts' -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - contextBreakdown: ContextBreakdownState - } -} - const tokenCount = z.number().int().nonnegative() const sessionSeq = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index f721d26219..552e5d9620 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -23,26 +23,22 @@ import { SessionLogOffset, SessionSeq, } from '@deepseek-ai/dsh-session' -// Type-only: activates the `ctx.sessionProjections` Context declaration. -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { + ContextPressureProjection, TokenMeasurement, TokenMeasurementBaseline, TokenMeterConfig, } from './types.ts' import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts' import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts' +import type { ContextPressureState } from './usage-projection.ts' import { estimateContent, estimateMessage, estimateToolsTokens, ROLE_OVERHEAD } from './estimate.ts' import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts' import type { MeterSurfaceNode } from './surface-fold.ts' import { priceSurface } from './route-pricing.ts' export type * from './types.ts' -// Module-edge re-export: forces the emitted index.d.ts to import the -// projection-unit modules, so their SessionProjectionStateMap augmentations load -// in aggregate programs that only import the package root. -export type * from './usage-projection.ts' -export type * from './breakdown-projection.ts' /** * Raw anchor facts captured at the latest successful call; the baseline is @@ -106,14 +102,18 @@ export class TokenMeter extends Service { static inject = ['sessionProjections'] private readonly states = new WeakMap() + private contextPressureProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') validateConfigKeys(config) + } - ctx.sessionProjections.register(tokenUsageProjectionDefinition) - ctx.sessionProjections.register(contextPressureProjectionDefinition) - ctx.sessionProjections.register(contextBreakdownProjectionDefinition) + protected async [Service.init](): Promise { + const ctx = this.ctx + await ctx.sessionProjections.register(tokenUsageProjectionDefinition) + this.contextPressureProjectionHandle = await ctx.sessionProjections.register(contextPressureProjectionDefinition) + await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. @@ -122,6 +122,19 @@ export class TokenMeter extends Service { }) } + /** + * Read the latest logged provider prompt size and route capacity. + * @param session - Session whose committed usage and request capacity are read. + * @returns context pressure; fields remain absent until their source events are recorded. + */ + contextPressure(session: Session): Pick { + const { pressureTokens, contextWindow } = this.contextPressureProjectionHandle.stateOf(session) + return { + ...pressureTokens === undefined ? {} : { pressureTokens }, + ...contextWindow === undefined ? {} : { contextWindow }, + } + } + /** * Measure current request pressure and surface through the durable tail. * diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 2238316fab..f07fa676a9 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -85,13 +85,6 @@ function usageOf(event: SessionEvent): TokenUsage | undefined { return lastAssistantStreamChunk(event.data.stream, 'usage')?.usage } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - tokenUsage: TokenUsageState - contextPressure: ContextPressureState - } -} - /** The context-pressure state schema and source of its inferred type. */ const contextPressureStateSchema = z.object({ contextWindow: z.number().int().positive().optional(), @@ -105,7 +98,8 @@ const contextPressureStateSchema = z.object({ }).optional(), }).strict() -type ContextPressureState = z.infer +/** Host fold state for provider pressure and surface adjustments. */ +export type ContextPressureState = z.infer /** * Token-meter's session projection unit. diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 9818d45163..c1995a3466 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -389,8 +389,9 @@ describe('contextBreakdown session projection', () => { const first = appendUser(session, 'the first of many messages') for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`) const last = appendUser(session, 'the last message before compaction') + const projection = await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) const state = () => { - const current = ctx.sessionProjections.stateOf(session, 'contextBreakdown') + const current = projection.stateOf(session) if (current === undefined) throw new Error('registered context breakdown has no state') return current } @@ -521,11 +522,12 @@ describe('contextBreakdown session projection', () => { it('expires an armed shadow price at the next event so a later replacement folds at zero delta', async () => { const { ctx, session } = await harness() + const breakdown = await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) const first = appendUser(session, 'first message') const last = appendUser(session, 'last message') appendSummaryMeter(ctx, session, first, last) session.append('step/start', { turn: 1, step: 1 }) - expect(ctx.sessionProjections.stateOf(session, 'contextBreakdown')).not.toHaveProperty('claim') + expect(breakdown.stateOf(session)).not.toHaveProperty('claim') const before = projected(ctx, session) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' }, diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index bf11f1b039..08959f288e 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -1,5 +1,5 @@ import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LlmRuntime, LlmAdapter, createMessage, createToolResultMessage, createUserMessage, projectFilesToText, ToolCallId, @@ -76,6 +76,7 @@ interface Harness { async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) new SessionProjectionRegistry(ctx) ctx.provide('attachments', { fileHostPath: (ref: FileAttachmentRef) => `/host/${ref.name}`, @@ -85,7 +86,8 @@ async function harness(pricing: (model: string) => LlmImageRequestPricing | unde } as never) const llm = new LlmRuntime(ctx) llm.registerAdapter(['mock'], new PricingAdapter(pricing)) - const meter = new TokenMeter(ctx) + await ctx.plugin(TokenMeter) + const meter = ctx.tokenMeter return { ctx, meter, session: Session.create(SessionId('route-priced'), undefined, undefined, undefined, [imageOffloadProjection]) } } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index f207905573..818fa45921 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -106,8 +106,7 @@ function appendSuccessfulCall( function meter(config: TokenMeterConfig = {}): TokenMeter { const ctx = new Context() - // The registry is a required injection of the service (its three projection - // units register in the constructor); mount it synchronously. + // Pure measurement tests use the service without mounting its projection contributions. new SessionProjectionRegistry(ctx) return new TokenMeter(ctx, config) } @@ -137,8 +136,13 @@ describe('TokenMeter configuration and registration', () => { await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TokenMeter) expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter) + const session = ctx.sessions.create() + expect(Object.keys(ctx.sessionProjections.checkpoint(session)).sort()) + .toEqual(['contextBreakdown', 'contextPressure', 'tokenUsage']) await fiber.dispose() expect(ctx.get('tokenMeter')).toBeUndefined() + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + await ctx.fiber.dispose() }) }) diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index b34e6e31ff..d14e3dd387 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -361,14 +361,14 @@ function appendAssistant( describe('contextPressure session projection', () => { it('serves no pressure or capacity for an empty log', async () => { const { ctx, session } = await harness() - expect(pressure(ctx, session)).toEqual({}) + expect(ctx.tokenMeter.contextPressure(session)).toEqual({}) }) it('does not synthesize zero pressure before a provider usage sample', async () => { const { ctx, session } = await harness() startStep(session, 1, 1) recordContext(session, 'small', 64_000) - expect(pressure(ctx, session)).toEqual({ contextWindow: 64_000 }) + expect(ctx.tokenMeter.contextPressure(session)).toEqual({ contextWindow: 64_000 }) }) it('sums prompt-side buckets and excludes response output', async () => { @@ -382,7 +382,8 @@ describe('contextPressure session projection', () => { }, 1, 1) // Output is deliberately absent: occupancy describes the prompt that was // sent, so it holds still while the response streams. - expect(pressure(ctx, session).pressureTokens).toBe(125) + expect(ctx.tokenMeter.contextPressure(session)).toEqual({ pressureTokens: 125 }) + expect(pressure(ctx, session)).toEqual({ pressureTokens: 125, projectedTokens: 125 }) }) it('replaces pressure with the newest request rather than accumulating', async () => { diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index fcd9bc5201..da73c82206 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -39,8 +39,8 @@ en: 0acc88b042a99380 zh: e4cf66b77f2fc7ae /deepseek-ai-dsh-plan-mode/understand-the-implementation/durable-state-and-step-boundary-appends: - en: ff946ab59a7b4a0f - zh: b1c79937770f57ad + en: 24166a02b6e81553 + zh: 24313c2153c9a6a6 /deepseek-ai-dsh-plan-mode/understand-the-implementation/the-plan-command: en: a7bf35c2a9da52bc zh: dea9f4b3a41f1a6d @@ -48,8 +48,8 @@ en: 56798d0f45fb5775 zh: 282e4a522ce6dae0 /deepseek-ai-dsh-plan-mode/understand-the-implementation/session-projection-unit: - en: 74cd6bdf50af51f5 - zh: 67609cf7202ececb + en: 396d66816890f715 + zh: fab5d67c027b33f5 /deepseek-ai-dsh-plan-mode/understand-the-implementation/source-map: en: fa97e858495a8105 zh: 8be1fbd7e420ff8a diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 35084ddb60..6b0ecefb81 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -82,7 +82,7 @@ Plan mode is a product package, not a capability seam: there is no swappable bac ### Durable state and step-boundary appends -The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the registered `plan` projection; the first dependent access fails explicitly when the registry or key is absent. +The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the retained `plan` projection handle, acquired during activation. ### The `/plan` command @@ -94,7 +94,9 @@ The command child activates only when a commands service is composed. It maps ba ### Session projection unit -When `ctx.sessionProjections` is composed, the package registers the `plan` unit through optional injection. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. Plan-mode reads require this unit and the `turnBoundary` unit, and fail explicitly if the registry or either key is absent. +The package requires `ctx.sessionProjections` and awaits registration of the `plan` unit during activation. Its prompt section is registered before that wait. Agent prompt assembly fails explicitly while the reader is unavailable, so a restored plan cannot run without its guidance. Callers must await plugin activation before running an agent. + +The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. State reads use the retained handle and remain available after unregistration. ### Source map diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 9d7023df69..3577205475 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -82,7 +82,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### 持久状态与步骤边界追加 -本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并读取已注册的 `plan` 投影;注册表或 key 缺失时,第一次依赖它们的访问会显式失败。 +本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并通过激活期间取得并保留的 `plan` 投影句柄读取状态。 ### `/plan` 命令 @@ -94,7 +94,9 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### 会话投影单元 -组合了 `ctx.sessionProjections` 时,本包通过可选注入注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。plan-mode 读取要求该单元与 `turnBoundary` 单元存在;注册表或任一 key 缺失时都会显式失败。 +本包要求 `ctx.sessionProjections`,并在激活期间等待 `plan` 单元注册完成。提示词段落在等待前注册。读取句柄不可用时,Agent 提示词装配会明确失败,避免恢复的计划模式缺少指引仍继续运行。调用方必须等待插件激活完成后再运行 Agent。 + +该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。状态读取使用保留的句柄,注销后仍然可用。 ### 源码地图 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index a2385491ef..cddeafab0b 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -33,7 +33,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import { UserQuestionError, type AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions' import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { PlanProjection, PlanUnitState } from './types.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -171,12 +171,15 @@ export const planProjectionDefinition = { * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, * the `plan:policy` section, the `/plan` command, and the stable exit tool. * Client carriers expose the projection's cropped `{ active, pending }` view. + * Agent prompt assembly fails while the projection reader is not ready; + * callers must await plugin activation before running an agent. */ export class PlanModeController extends Service { - static inject = ['tools', 'systemPrompt', 'sessionProjections'] + static inject = ['tools', 'systemPrompt', 'sessionProjections', 'agents'] /** Validated deployment-owned guidance. */ private readonly section: string + private planProjectionHandle: ProjectionHandle | undefined /** * Latest selection per session awaiting the next accepted in-turn pre-step. @@ -188,6 +191,20 @@ export class PlanModeController extends Service { constructor(ctx: Context, config: PlanModeConfig = { section: '' }) { super(ctx, 'planMode') this.section = resolveConfig(config).section + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + ctx.systemPrompt.section({ + name: 'plan:policy', + order: ctx.systemPrompt.getSectionOrder('PLAN_POLICY'), + text: (context) => { + if (context.agent === undefined) return '' + const pending = this.pendingIntents.get(context.agent.session) + return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : '' + }, + }) + this.planProjectionHandle = await ctx.sessionProjections.register(planProjectionDefinition) let disposed = false // Pre-step is outside Session.append publication, so it can append the // log-only mode event inside an open turn without re-entering the session. @@ -213,18 +230,6 @@ export class PlanModeController extends Service { }) ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime') - ctx.systemPrompt.section({ - name: 'plan:policy', - order: ctx.systemPrompt.getSectionOrder('PLAN_POLICY'), - text: (context) => { - if (context.agent === undefined) return '' - const pending = this.pendingIntents.get(context.agent.session) - return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : '' - }, - }) - - ctx.sessionProjections.register(planProjectionDefinition) - // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ @@ -377,20 +382,18 @@ export class PlanModeController extends Service { } private hasOpenTurn(session: Session): boolean { - const state = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') - if (state === undefined) throw new Error('plan-mode requires the turnBoundary session projection') - return state.openTurnStartSeq !== null + return this.ctx.agents.turnBoundaryOf(session).openTurnStartSeq !== null } private loggedActiveAtLastHeader(session: Session): boolean | undefined { return this.planState(session).activeAtLastHeader ?? undefined } - /** Read the required plan projection state or fail at the first service access. */ private planState(session: Session): PlanUnitState { - const state = this.ctx.sessionProjections.stateOf(session, 'plan') - if (state === undefined) throw new Error('plan-mode requires the plan session projection') - return state + if (this.planProjectionHandle === undefined) { + throw new Error('plan mode is not ready; await plugin activation before running an agent') + } + return this.planProjectionHandle.stateOf(session) } /** diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index 3325c6b0c4..79fbaa3617 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -36,10 +36,6 @@ export interface PlanUnitState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Host plan-mode fold state. */ - plan: PlanUnitState - } interface SessionProjectionMap { /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */ plan: PlanProjection diff --git a/packages/plan/plan-mode/tests/initialization.spec.ts b/packages/plan/plan-mode/tests/initialization.spec.ts new file mode 100644 index 0000000000..781a42d274 --- /dev/null +++ b/packages/plan/plan-mode/tests/initialization.spec.ts @@ -0,0 +1,62 @@ +/** Prompt assembly must not omit plan guidance during activation. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import PlanModeController from '@deepseek-ai/dsh-plan-mode' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +describe('plan-mode activation', () => { + it.each([false, true])('rejects premature requests with restored active=%s, then includes the complete plan contribution', async (active) => { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new MockAdapter([textResponse('ready')]) + ctx.llm.registerAdapter(['mock'], adapter) + const seed = Session.create(SessionId('plan-seed')) + seed.append('plan/mode', { active }) + const { agent } = await ctx.agents.create({ + sessionId: SessionId('initial-plan'), seed: seed.snapshotEvents(), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'plan') { + entered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(PlanModeController, { section: 'Plan the work before executing it.' }) + await entered.promise + const errors: unknown[] = [] + ctx.on('agent/error', ({ error }) => { errors.push(error) }) + const prompt = createUserMessage({ content: [{ type: 'text', text: 'continue the task' }], source: { kind: 'user' } }) + agent.followup(prompt) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(0) + expect(errors.some(error => error instanceof Error && /plan.*not ready/.test(error.message))).toBe(true) + expect(agent.session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(0) + + release.resolve(undefined) + await fiber + agent.followup(prompt) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('exit_plan_mode') + const text = adapter.requests[0]?.messages.flatMap(message => message.content) + .filter(block => block.type === 'text').map(block => block.text).join('\n') + expect(text?.includes('Plan the work before executing it.')).toBe(active) + expect(ctx.planMode.get(agent)).toEqual({ active }) + }) +}) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 5c39a792e8..1068721786 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -56,8 +56,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Read the plan unit that backs the service in this full composition. */ function planActive(ctx: Context, agent: Agent): boolean { - const state = ctx.sessionProjections.stateOf(agent.session, 'plan') - if (state === undefined) throw new Error('plan projection is not registered') + const state = ctx.planMode.get(agent) return state.active } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 6024269b6d..9bc5917ea8 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -13,7 +13,6 @@ import UserQuestionService, { import CommandRuntime from '@deepseek-ai/dsh-commands' import { PtcRuntime, type PtcRunRequest, type PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import PlanModeController, { EXIT_PLAN_MODE, planProjectionDefinition, resolveConfig } from '../src/index.ts' import type { PlanModeConfig } from '../src/index.ts' import type { PlanUnitState } from '../src/types.ts' @@ -89,7 +88,7 @@ function foldPlanMode(events: readonly SessionEvent[], end = events.length): boo async function mountProjectionSeam(ctx: Context): Promise { await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) } async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { @@ -231,25 +230,6 @@ describe('ctx.planMode: get/set', () => { expect(ctx.get('planMode')).toBeUndefined() }) - it('fails when the required plan projection key is absent', async () => { - const ctx = await setup() - const agent = await agentWithSession(ctx, 'missing-plan-projection') - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - expect(() => ctx.planMode.get(agent)).toThrow('plan-mode requires the plan session projection') - }) - - it('registers plan state directly but requires turnBoundary state', async () => { - const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - const agent = await agentWithSession(ctx, 'missing-plan-projection-keys') - const planMode = new PlanModeController(ctx, PLAN_CONFIG) - await new Promise(resolve => setImmediate(resolve)) - expect(planMode.get(agent)).toEqual({ active: false }) - expect(() => planMode.set(agent, true)).toThrow('plan-mode requires the turnBoundary session projection') - }) - it('reads the folded state', async () => { const ctx = await setup() const agent = await agentWithSession(ctx) @@ -789,7 +769,6 @@ describe('/plan', () => { describe('exit_plan_mode', () => { async function setupWithReview(answer?: { selected: string[]; custom?: string }) { const ctx = await setup() - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] if (answer !== undefined) { @@ -869,7 +848,6 @@ describe('exit_plan_mode', () => { it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => { const ctx = await setup() - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })) registerQuestionAnswerer(ctx, { ask }) @@ -926,7 +904,6 @@ describe('exit_plan_mode', () => { provideWorkingDirectoryFixture(ctx) await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] registerQuestionAnswerer(ctx, { @@ -1105,7 +1082,6 @@ describe('exit_plan_mode', () => { await ctx.plugin(ToolRuntime) await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void registerQuestionAnswerer(ctx, { diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 3c2fc36b5c..8809293a5d 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -25,8 +25,8 @@ async function harness(withPlanMode: boolean): Promise { await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withPlanMode) await ctx.plugin(PlanModeController, { section: 'plan policy' }) const session = ctx.sessions.create() await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) @@ -118,22 +118,22 @@ describe('plan projection unit', () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBeNull() + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: null }) bench.session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial', }) expect(bench.values().plan).toEqual({ active: true, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true }) commitPlanMode(bench.session, false, 1) expect(bench.values().plan).toEqual({ active: false, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true }) bench.session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'change', }) expect(bench.values().plan).toEqual({ active: false, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(false) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: false }) }) it('has no plan key when plan-mode is not composed', async () => { diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml index ae11417a45..09ae85b7e6 100644 --- a/packages/preset/agent-preset-registry/README.i18n.yaml +++ b/packages/preset/agent-preset-registry/README.i18n.yaml @@ -21,8 +21,8 @@ en: cd28d9b4f46c46a8 zh: 4921d853f98469fe /deepseek-ai-dsh-agent-preset-registry/understand-the-implementation: - en: 7df811c1347b9a94 - zh: 141a7feb5ca597fe + en: 32c8a5c63022e275 + zh: 1d53e28b0d7375da /deepseek-ai-dsh-agent-preset-registry/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md index 02530d05d3..bc4b722daf 100644 --- a/packages/preset/agent-preset-registry/README.md +++ b/packages/preset/agent-preset-registry/README.md @@ -57,7 +57,7 @@ Each declaration eagerly creates a registry-owned scope and an in-memory Loader A preset service provider and its consumers must share one `cordis:group` isolation realm. Entry-local isolation does not propagate through the Agent registration scope: ordinary Context lookup outside the group, including `agent.ctx`, still selects the Host realm. `agentPresets.serviceFor()` explicitly inspects the retained composition; it does not change ordinary service lookup. -Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. `select` leaves a blank Session that records the requested preset and already runs its current revision unchanged, without another selection record or `tools/change`. +Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. `presetForSession(session)` synchronously reads the latest recorded preset ID, or null when none is recorded. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. `select` leaves a blank Session that records the requested preset and already runs its current revision unchanged, without another selection record or `tools/change`. `inspectCompositions()` reads all revisions retained by this registry; passing an Agent context selects its exact revision, or returns an empty list when it has no binding here. Each `AgentPresetInspection` contains a preset ID, detached active-module references with their resolution bases, and leaked-service names. Inspectors query the runtime service rather than a module-local mount table; the results contain no Loader trees or fibers. [composition-inventory.ts](src/composition-inventory.ts) defines these records. diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md index 334a72fd96..5d9a105c42 100644 --- a/packages/preset/agent-preset-registry/README.zh.md +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -57,7 +57,7 @@ Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注 preset 的服务提供方及其消费方必须共享同一个 `cordis:group` 隔离 realm。条目局部隔离不会沿 Agent 注册 scope 传播:组外的普通 Context 查找(包括 `agent.ctx`)仍选择 Host realm。`agentPresets.serviceFor()` 显式检查保留的组合,不改变普通服务查找。 -激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。若空白会话已记录所请求的 preset 且正运行其当前版本,`select` 不做任何改动,也不追加切换记录或触发 `tools/change`。 +激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。`presetForSession(session)` 同步读取最新记录的预设 ID,未记录时返回 null。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。若空白会话已记录所请求的 preset 且正运行其当前版本,`select` 不做任何改动,也不追加切换记录或触发 `tools/change`。 `inspectCompositions()` 读取本注册表仍保留的全部代际;传入 Agent 上下文时只选择它绑定的精确代际,在此没有绑定时返回空列表。每个 `AgentPresetInspection` 包含 preset ID、纯数据形式的活动模块引用及解析基址,以及泄漏的服务名。检查器查询运行中的服务,而不是模块级挂载表;结果不包含 Loader 树或 fiber。这些记录定义在 [composition-inventory.ts](src/composition-inventory.ts) 中。 diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index c76d7bba06..5205a58228 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -1,11 +1,13 @@ /** Declarative Agent capability sets, activation and session binding. */ -import { Context } from '@deepseek-ai/cordis' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, scopeParentOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { dump } from 'js-yaml' import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Session } from '@deepseek-ai/dsh-session' // Type-only: the optional `settings` service this registry keeps off the generated pages. import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-tools' @@ -50,11 +52,12 @@ interface Binding { /** Registry of YAML-declared presets and the revisions live Agents retain. */ export class AgentPresetRegistry extends TypertRemoteService { - static inject = ['loader', 'sessionProjections'] + static inject = ['loader', 'sessionProjections', 'agents'] static Config = z.object({ default: z.string().required(), selectedDefault: z.string().volatile(), }) + private agentPresetProjectionHandle!: ProjectionHandle private readonly owner: Context private readonly definitions = new Map() private readonly generations = new Map() @@ -64,7 +67,11 @@ export class AgentPresetRegistry extends TypertRemoteService { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.owner = ctx - ctx.sessionProjections.register(agentPresetProjectionDefinition) + } + + protected async [Service.init](): Promise { + const ctx = this.owner + this.agentPresetProjectionHandle = await ctx.sessionProjections.register(agentPresetProjectionDefinition) ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) ctx.on('session/event', (session, event) => { if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) @@ -74,6 +81,15 @@ export class AgentPresetRegistry extends TypertRemoteService { /** Default preset for a subsequently created session. */ get defaultId(): string { return this.config.selectedDefault.get() ?? this.config.default } + /** + * Read the preset recorded for a Session, including selections no longer in the roster. + * @param session - Session whose committed preset selection is read. + * @returns the recorded preset, or null when none was selected. + */ + presetForSession(session: Session): string | null { + return this.agentPresetProjectionHandle.stateOf(session) + } + /** Register and eagerly load a definition; activation failure remains visible in the roster. * @param definition Parsed configuration supplied by the declaring plugin. * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. @@ -348,13 +364,13 @@ export class AgentPresetRegistry extends TypertRemoteService { @Remote('select') async select(agent: Agent, agentPreset: string): Promise { const turn = (this.switches.get(agent.id) ?? Promise.resolve()).then(async () => { - const boundary = this.owner.sessionProjections.stateOf(agent.session, 'turnBoundary') - if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { + const boundary = this.owner.agents.turnBoundaryOf(agent.session) + if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) { throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset }) } const current = this.definitions.get(agentPreset)?.generation if (current !== undefined && this.generationFor(agent.ctx) === current - && this.owner.sessionProjections.stateOf(agent.session, 'agentPreset') === agentPreset) return agentPreset + && this.agentPresetProjectionHandle.stateOf(agent.session) === agentPreset) return agentPreset const preset = await this.recompose(agent.ctx, agentPreset) agent.session.append('agent-preset/selected', { agentPreset: preset.id }) return preset.id diff --git a/packages/preset/agent-preset-registry/src/types.ts b/packages/preset/agent-preset-registry/src/types.ts index c30141d621..0875990d11 100644 --- a/packages/preset/agent-preset-registry/src/types.ts +++ b/packages/preset/agent-preset-registry/src/types.ts @@ -48,9 +48,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - agentPreset: string | null - } interface SessionProjectionMap { /** Preset the Session runs, or null when the deployment composes none. */ agentPreset: string | null diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 2456c862f0..2c9c4486f3 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-scope' -import { assembleContextFor } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import { entryListProblem } from '../src/index.ts' import { currentKey, harness, declare, contribution, agentOn, liveRegistries, plugin } from './harness.ts' import { omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' @@ -108,7 +108,7 @@ describe('declarative preset revisions', () => { await declare(ctx, contribution('minimal')) const agent = await agentOn(ctx, 'selection') expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal') agent.session.append('turn/start', { turn: 1 }) await expect(ctx.agentPresets.select(agent, 'standard')).rejects.toThrow('already started') }) @@ -246,6 +246,7 @@ it('ignores a retired modeSelectionEnabled field in the user patch', async () => it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(async (ctx) => { await ctx.plugin(Loader) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) return ctx.plugin(AgentPresets, { default: 'standard' }) })) @@ -276,7 +277,7 @@ it('serializes competing blank-session selections and recovers after a failed se ctx.agentPresets.select(agent, 'absent'), ctx.agentPresets.select(agent, 'minimal'), ctx.agentPresets.select(agent, 'standard'), ]) expect(results.map(row => row.status)).toEqual(['rejected', 'fulfilled', 'fulfilled']) - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('standard') }) it('retains a newly acquired generation when a previous activation is replaced', async () => { @@ -317,11 +318,11 @@ it('logs the default preset a blank Session runs once it is selected without a r const ctx = await setup() await declare(ctx, contribution('standard')) const agent = await agentOn(ctx, 'unrecorded') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBeNull() + expect(ctx.agentPresets.presetForSession(agent.session)).toBeNull() expect(await ctx.agentPresets.select(agent, 'standard')).toBe('standard') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard') + expect(ctx.agentPresets.presetForSession(agent.session)).toBe('standard') }) it('recomposes and logs a repeated selection once the preset definition was replaced', async () => { @@ -348,7 +349,7 @@ it('does not lose the durable selection when a tools observer rejects notificati const agent = await agentOn(ctx, 'observer') ctx.on('tools/change', () => { throw new Error('observer refused') }) expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal') }) it('inventories failed conditional entries without executing their expressions again', async () => { diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 7060611d2f..a08936db39 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -185,6 +185,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | Package | Config | Description | |---|---|---| +| `@deepseek-ai/dsh-cua` | no | Shared Computer Use application objects, authorization, and logged tool execution | | `@deepseek-ai/dsh-experimental-agent-team` | yes | Implicit-root Agent Teams roster, direct peer messaging, and shared task DAG | | `@deepseek-ai/dsh-experimental-api-speech-to-text` | yes | Authenticated experimental speech transcription for browser clients | | `@deepseek-ai/dsh-experimental-auto-review` | no | Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset | diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 8f6e7f54cd..36fdc1f2e8 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -28,7 +28,7 @@ import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts' @@ -125,14 +125,6 @@ const sandboxModeStateSchema = zod.union([ zod.literal('danger-full-access'), ]).nullable() -type SandboxModeState = zod.infer -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Last logged sandbox-mode override, or null before one (deployment default applies at resolve time). */ - sandboxMode: SandboxModeState - } -} - /** * The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment * default mode, fallback workspace root, and current request-time policy @@ -157,6 +149,8 @@ export class SandboxPolicyService extends Service { readonly workspaceRoot: string /** The frozen extra writable directories shared by every resolved policy, or `undefined` for none. */ readonly additionalWritableRoots: readonly string[] | undefined + private sandboxModeProjectionHandle!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'sandboxPolicy') // schemastery (static Config) already filled `mode`; the cast records that @@ -165,8 +159,11 @@ export class SandboxPolicyService extends Service { this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) this.additionalWritableRoots = resolveAdditionalWritableRoots(config.additionalWritableRoots) + } - ctx.sessionProjections.register({ + protected async [Service.init](): Promise { + const ctx = this.ctx + this.sandboxModeProjectionHandle = await ctx.sessionProjections.register({ key: 'sandboxMode', stateVersion: 1, stateSchema: sandboxModeStateSchema, @@ -213,7 +210,7 @@ export class SandboxPolicyService extends Service { * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return this.ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? undefined + return this.sandboxModeProjectionHandle.stateOf(session) ?? undefined } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 5f3916bb21..15878013fa 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -279,10 +279,10 @@ describe('the sandbox/mode session kit', () => { it('the sandboxMode projection folds to the last switch, or null without one', async () => { const ctx = await mounted() const session = Session.create(SessionId('sess-fold')) - expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBeNull() + expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBeNull() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') - expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBe('read-only') + expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBe('read-only') }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 17ca577e57..d25dda9871 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 28748ace0a..492730d1c1 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -4,6 +4,20 @@ */ import { Temporal } from '@js-temporal/polyfill' +import { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone as canonicalizeSharedTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + isFourDigitYearInstant, + parseLocalCalendar, + parseOffsetInstant, + resolveLocalInstant, +} from '@deepseek-ai/dsh-util-time' +import type { + InstantRejection, LocalCalendarRejection, LocalCalendarTime, TimeZoneRejection, +} from '@deepseek-ai/dsh-util-time' import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { @@ -50,19 +64,7 @@ export const MAX_TITLE_LENGTH = 120 */ export const CRON_SEARCH_HORIZON_YEARS = 400 -const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z') -const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z') -const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/ -const OFFSET_INSTANT = new RegExp( - String.raw`^(?\d{4})-(?\d{2})-(?\d{2})` - + String.raw`T(?\d{2}):(?\d{2}):(?\d{2})` - + String.raw`(?:\.(?\d{1,3}))?(?Z|(?[+-])` - + String.raw`(?\d{2}):(?\d{2}))$`, -) -const LOCAL_DATE = /^(?\d{4})-(?\d{2})-(?\d{2})$/ -const LOCAL_TIME = /^(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d{1,3}))?$/ const LOCAL_CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?$/ -const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ /** Error from malformed or transition-invalid durable Schedule data. */ export class ScheduleLogError extends Error { @@ -252,116 +254,63 @@ function decodeId(value: unknown): ScheduleIdType { return ScheduleId(value) } +/** Stable Schedule diagnostic for a `scheduledAt` outside the canonical profile. */ +const NON_CANONICAL_INSTANT_MESSAGE = 'scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant' + /** Validate one canonical four-digit-year UTC instant. */ function decodeInstant(value: unknown): string { - if (typeof value !== 'string' || !UTC_INSTANT.test(value)) { - throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant') - } - const epoch = Date.parse(value) - if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) { - throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant') + if (typeof value !== 'string') { + throw new ScheduleLogError(NON_CANONICAL_INSTANT_MESSAGE) } + const parsed = fourDigitYearEpoch(value) + if (parsed.kind === 'rejected') throw new ScheduleLogError(instantDiagnostic(parsed.reason)) return value } -interface CalendarParts { - readonly year: number - readonly month: number - readonly day: number - readonly hour: number - readonly minute: number - readonly second: number - readonly millisecond: number +/** + * Map one shared instant rejection onto the stable stored-data diagnostic. + * @param reason - Reason the shared vocabulary refused the instant. + * @returns The Schedule log diagnostic for that refusal. + */ +function instantDiagnostic(reason: InstantRejection): string { + switch (reason) { + case 'not_a_calendar_date': + return 'scheduledAt is not a real UTC calendar instant' + case 'not_rfc3339': + case 'out_of_range': + case 'invalid_numeric_offset': + return NON_CANONICAL_INSTANT_MESSAGE + /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ + default: { + const unreachable: never = reason + throw new ScheduleLogError(`unknown instant rejection ${String(unreachable)}`) + } + } } -/** Read one required named regular-expression group as a number. */ -function groupNumber(groups: Record, name: string): number { - const value = groups[name] - /* v8 ignore next -- successful fixed regexes always provide every requested group. */ - if (value === undefined) throw new ScheduleInputError('invalid_rule', 'The at value has an invalid shape.') - return Number(value) -} - -/** Convert exact calendar fields to a UTC-shaped epoch while rejecting normalization. */ -function calendarEpoch(parts: CalendarParts): number { - const value = new Date(0) - value.setUTCHours(0, 0, 0, 0) - value.setUTCFullYear(parts.year, parts.month - 1, parts.day) - value.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond) - const epoch = value.getTime() - if (!Number.isFinite(epoch) - || value.getUTCFullYear() !== parts.year - || value.getUTCMonth() + 1 !== parts.month - || value.getUTCDate() !== parts.day - || value.getUTCHours() !== parts.hour - || value.getUTCMinutes() !== parts.minute - || value.getUTCSeconds() !== parts.second - || value.getUTCMilliseconds() !== parts.millisecond) { - throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.') +/** + * Map one shared zone rejection onto the stable Schedule input code. + * @param reason - Reason the shared vocabulary refused the name. + * @param cause - Platform refusal the shared vocabulary caught, when it carried one. + * @returns The public Schedule diagnostic for that refusal. + */ +function timeZoneError(reason: TimeZoneRejection, cause?: unknown): ScheduleInputError { + const options = cause === undefined ? undefined : { cause } + switch (reason) { + case 'blank': + case 'padded': + case 'not_area_location': + case 'unsupported': + return new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.', options) + /* v8 ignore next 2 -- the shared parser does not answer not_canonical_shape for a canonical zone. */ + case 'not_canonical_shape': + return new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.', options) + /* v8 ignore next 3 -- TimeZoneRejection is closed; this arm only makes adding a reason a compile error. */ + default: { + const unreachable: never = reason + return unreachable + } } - return epoch -} - -/** Normalize an optional one-to-three digit fractional second to milliseconds. */ -function milliseconds(value: string | undefined): number { - return value === undefined ? 0 : Number(value.padEnd(3, '0')) -} - -/** Require a safe, representable, strictly future UTC target. */ -function futureInstant(epoch: number, now: number): string { - if (!Number.isSafeInteger(now) || !Number.isSafeInteger(epoch) - || epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS) { - throw new ScheduleInputError( - 'time_out_of_range', - 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', - ) - } - if (epoch <= now) { - throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.') - } - const instant = new Date(epoch).toISOString() - /* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */ - if (!UTC_INSTANT.test(instant)) { - throw new ScheduleInputError( - 'time_out_of_range', - 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', - ) - } - return instant -} - -/** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */ -function parseOffsetInstant(value: string): number { - const match = OFFSET_INSTANT.exec(value) - const groups = match?.groups - if (groups === undefined) { - throw new ScheduleInputError( - 'invalid_rule', - 'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.', - ) - } - const parts: CalendarParts = { - year: groupNumber(groups, 'year'), - month: groupNumber(groups, 'month'), - day: groupNumber(groups, 'day'), - hour: groupNumber(groups, 'hour'), - minute: groupNumber(groups, 'minute'), - second: groupNumber(groups, 'second'), - millisecond: milliseconds(groups['fraction']), - } - if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { - throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.') - } - const localEpoch = calendarEpoch(parts) - if (groups['zone'] === 'Z') return localEpoch - const offsetHour = groupNumber(groups, 'offsetHour') - const offsetMinute = groupNumber(groups, 'offsetMinute') - if (offsetHour > 23 || offsetMinute > 59 - || (groups['sign'] === '-' && offsetHour === 0 && offsetMinute === 0)) { - throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.') - } - const direction = groups['sign'] === '+' ? 1 : -1 - return localEpoch - direction * (offsetHour * 60 + offsetMinute) * 60_000 } /** @@ -370,69 +319,84 @@ function parseOffsetInstant(value: string): number { * @returns The runtime's canonical IANA name. */ export function canonicalizeTimeZone(value: string): string { - if (value.length === 0 || value.trim() !== value || (value !== 'UTC' && !IANA_ZONE.test(value))) { - throw new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.') - } - let canonical: string - try { - canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone - } catch (error: unknown) { - throw new ScheduleInputError( - 'invalid_time_zone', - 'time_zone must be UTC or a valid IANA Area/Location name.', - { cause: error }, - ) - } - /* v8 ignore next -- Intl returns the requested canonical zone or an IANA canonical alias. */ - if (canonical !== 'UTC' && !IANA_ZONE.test(canonical)) { - throw new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') - } - return canonical + const resolved = canonicalizeSharedTimeZone(value) + if (resolved.kind === 'rejected') throw timeZoneError(resolved.reason, resolved.cause) + return resolved.timeZone } /** Parse strict local calendar fields without consulting a process time zone. */ -function parseLocalAt(value: LocalAtInput): CalendarParts { - const dateMatch = LOCAL_DATE.exec(value.date) - const timeMatch = LOCAL_TIME.exec(value.time) - const date = dateMatch?.groups - const time = timeMatch?.groups - if (date === undefined || time === undefined) { - throw new ScheduleInputError( - 'invalid_rule', - 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.', - ) +function parseLocalAt(value: LocalAtInput): LocalCalendarTime { + const parsed = parseLocalCalendar(value.date, value.time) + if (parsed.kind === 'rejected') { + throw new ScheduleInputError('invalid_rule', localCalendarDiagnostic(parsed.reason)) } - const parts: CalendarParts = { - year: groupNumber(date, 'year'), - month: groupNumber(date, 'month'), - day: groupNumber(date, 'day'), - hour: groupNumber(time, 'hour'), - minute: groupNumber(time, 'minute'), - second: groupNumber(time, 'second'), - millisecond: milliseconds(time['fraction']), - } - if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { - throw new ScheduleInputError('invalid_rule', 'The local at value must be a real ISO calendar date and time.') - } - calendarEpoch(parts) - return parts + return parsed.time } -/** Resolve only the earlier overlap instant; a gap fails the local-field round trip. */ -function localInstant(local: Temporal.PlainDateTime, timeZone: string): number | undefined { - const zoned = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' }) - return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : undefined +/** Map one shared local-calendar rejection onto the stable Schedule diagnostic. */ +function localCalendarDiagnostic(reason: LocalCalendarRejection): string { + switch (reason) { + case 'malformed': + return 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.' + case 'not_a_calendar_date': + return 'The at value must be a real ISO calendar date and time.' + case 'out_of_range': + return 'The local at value must be a real ISO calendar date and time.' + /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ + default: { + const unreachable: never = reason + throw new Error(`unknown local calendar rejection ${String(unreachable)}`) + } + } } /** Resolve a local one-shot, rejecting nonexistent wall-clock times. */ -function resolveLocalInstant(parts: CalendarParts, timeZone: string): number { - const target = localInstant(Temporal.PlainDateTime.from(parts, { overflow: 'reject' }), timeZone) +function resolveLocalAt(parts: LocalCalendarTime, timeZone: string): number { + const target = resolveLocalInstant(parts, timeZone) if (target === undefined) { throw new ScheduleInputError('invalid_rule', 'The local at time does not exist in the selected time zone.') } return target } +/** Require a safe, representable, strictly future UTC target. */ +function futureInstant(epoch: number, now: number): string { + const instant = formatFourDigitYearInstant(epoch) + if (!Number.isSafeInteger(now) || instant === undefined) { + throw new ScheduleInputError( + 'time_out_of_range', + 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', + ) + } + if (epoch <= now) { + throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.') + } + return instant +} + +/** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */ +function parseAtOffsetInstant(value: string): number { + const parsed = parseOffsetInstant(value) + if (parsed.kind === 'accepted') return parsed.epoch + switch (parsed.reason) { + case 'not_rfc3339': + throw new ScheduleInputError( + 'invalid_rule', + 'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.', + ) + case 'invalid_numeric_offset': + throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.') + case 'not_a_calendar_date': + case 'out_of_range': + throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.') + /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ + default: { + const unreachable: never = parsed.reason + throw new Error(`unknown instant rejection ${String(unreachable)}`) + } + } +} + /** Decode the exact v1 after record shape. */ function decodeAfterRecord(value: unknown): LegacyAfterScheduleRecord { /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */ @@ -794,9 +758,7 @@ export function resolveEveryOccurrence( ): RecurringOccurrence { const target = Date.parse(record.scheduledAt) const interval = record.everySeconds * 1_000 - if (!Number.isSafeInteger(acceptedAt) - || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS - || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(acceptedAt)) { throw new ScheduleLogError('every acceptedAt must be a representable four-digit-year instant') } if (!Number.isSafeInteger(interval) || interval <= 0) { @@ -813,7 +775,7 @@ export function resolveEveryOccurrence( } const occurrenceAt = new Date(occurrence).toISOString() const next = occurrence + interval - if (!Number.isSafeInteger(next) || next > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(next)) { return Object.freeze({ occurrenceAt }) } return Object.freeze({ @@ -854,7 +816,7 @@ function nextMatchingTarget( } for (; Temporal.PlainDate.compare(date, lastDate) <= 0; date = date.add({ days: 1 })) { if (!matches(date)) continue - const target = localInstant(date.toPlainDateTime(time), timeZone) + const target = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (target === undefined) continue if (target > MAX_FOUR_DIGIT_YEAR_MS) return undefined if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > now) return new Date(target).toISOString() @@ -874,7 +836,7 @@ function latestDueOccurrence( let date = localDate(acceptedAt, 'UTC').add({ days: 1 }) for (;; date = date.subtract({ days: 1 })) { if (!matches(date)) continue - const candidate = localInstant(date.toPlainDateTime(time), timeZone) + const candidate = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (candidate === undefined || candidate > acceptedAt) continue // A committed target remains due even if current tzdata places the rule before it. return Math.max(candidate, savedTarget) @@ -883,8 +845,7 @@ function latestDueOccurrence( /** Resolve a wall-clock rule only when its decision is at a representable four-digit-year instant. */ function acceptedDecision(selector: string, acceptedAt: number): number { - if (!Number.isSafeInteger(acceptedAt) - || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(acceptedAt)) { throw new ScheduleLogError(`${selector} acceptedAt must be a representable four-digit-year instant`) } return acceptedAt @@ -1022,7 +983,7 @@ function nextCronTarget(parsed: ParsedCronExpression, timeZone: string, floor: n if (matches(date)) { for (const time of times) { if (date.equals(firstDate) && Temporal.PlainTime.compare(time, floorTime) < 0) continue - const target = localInstant(date.toPlainDateTime(time), timeZone) + const target = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (target === undefined) continue if (target > MAX_FOUR_DIGIT_YEAR_MS) return undefined if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > floor) return new Date(target).toISOString() @@ -1076,7 +1037,7 @@ function latestCronOccurrence( // An earliest time that a DST gap removes leaves the bound to the first time // that does exist, and a date with no existing time keeps the full walk. const earliest = times.reduce( - (found, time) => found ?? localInstant(date.toPlainDateTime(time), timeZone), + (found, time) => found ?? resolveLocalInstant(date.toPlainDateTime(time), timeZone), undefined, ) if (earliest !== undefined && earliest > decision) { @@ -1084,7 +1045,7 @@ function latestCronOccurrence( continue } for (const time of [...times].reverse()) { - const candidate = localInstant(date.toPlainDateTime(time), timeZone) + const candidate = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (candidate === undefined || candidate > decision || candidate < MIN_FOUR_DIGIT_YEAR_MS) continue // A committed target remains due even if current tzdata places the rule before it. return Math.max(candidate, savedTarget) @@ -1320,7 +1281,7 @@ export function createAtScheduleRecord( export function parseAtInput(at: AtInput): number { let target: number if (typeof at === 'string') { - target = parseOffsetInstant(at) + target = parseAtOffsetInstant(at) } else if (isRecord(at)) { if (!hasExactKeys(at, ['date', 'time', 'time_zone'])) { throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and time_zone.') @@ -1337,7 +1298,7 @@ export function parseAtInput(at: AtInput): number { time: at['time'], time_zone: rawTimeZone, } - target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone)) + target = resolveLocalAt(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone)) } else { throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.') } @@ -1407,7 +1368,7 @@ export function createDailyScheduleRecord( throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.') } const { time, timeZone } = parseDailyInput(daily) - if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(now)) { throw new ScheduleInputError('time_out_of_range', 'Daily creation time must be a representable four-digit-year UTC instant.') } const scheduledAt = nextDailyTarget(dailyTime(time), timeZone, now) @@ -1460,7 +1421,7 @@ export function createWeeklyScheduleRecord( throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.') } const { time, timeZone, weekdays } = parseWeeklyInput(weekly) - if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(now)) { throw new ScheduleInputError('time_out_of_range', 'Weekly creation time must be a representable four-digit-year UTC instant.') } const scheduledAt = nextWeeklyTarget(weeklyTime(time), timeZone, weekdays, now) @@ -1494,7 +1455,7 @@ export function createCronScheduleRecord( throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.') } const { expression, timeZone } = parseCronInput(cron) - if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(now)) { throw new ScheduleInputError('time_out_of_range', 'Cron creation time must be a representable four-digit-year UTC instant.') } const scheduledAt = nextCronTarget(parseCronExpression(expression), timeZone, now) diff --git a/packages/schedule/schedule/tests/domain.spec.ts b/packages/schedule/schedule/tests/domain.spec.ts index 79ede24bd4..d8e87222b4 100644 --- a/packages/schedule/schedule/tests/domain.spec.ts +++ b/packages/schedule/schedule/tests/domain.spec.ts @@ -22,6 +22,7 @@ import { resolveEveryOccurrence, scheduleView, } from '../src/domain.ts' +import type { LocalAtInput } from '../src/types.ts' function scheduleEvent(data: unknown, seq = 0): SessionEvent { return { type: 'schedule/change', seq, time: 1, data } as SessionEvent @@ -557,6 +558,24 @@ describe('absolute record and time-zone resolution', () => { )).toThrow(ScheduleInputError) }) + it('keeps the three local-calendar diagnostics distinct', () => { + const diagnostic = (at: LocalAtInput): string => { + try { + createAtScheduleRecord(ScheduleId('schedule-at'), 'x', at, now, 'x') + throw new Error('expected local selector failure') + } catch (error: unknown) { + expect(error).toBeInstanceOf(ScheduleInputError) + return (error as ScheduleInputError).message + } + } + expect(diagnostic({ date: '2026-02-30', time: '09:00:00', time_zone: 'UTC' })) + .toBe('The at value must be a real ISO calendar date and time.') + expect(diagnostic({ date: '2026-08-06', time: '24:00:00', time_zone: 'UTC' })) + .toBe('The local at value must be a real ISO calendar date and time.') + expect(diagnostic({ date: '2026/08/06', time: '09:00:00', time_zone: 'UTC' })) + .toBe('Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.') + }) + it('rejects empty prompts and local instants outside the four-digit range', () => { expect(() => createAtScheduleRecord( ScheduleId('schedule-at'), ' ', '2026-08-06T01:00:00Z', now, 'Title', diff --git a/packages/schedule/schedule/tests/harness.ts b/packages/schedule/schedule/tests/harness.ts index bf152ad187..768125a479 100644 --- a/packages/schedule/schedule/tests/harness.ts +++ b/packages/schedule/schedule/tests/harness.ts @@ -1,6 +1,7 @@ /** Host Schedule tests use real domain storage and an explicit Session resolver. */ import { Context, Service } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -72,6 +73,7 @@ export async function harness(options: { const ctx = new Context() options.onContext?.(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) diff --git a/packages/schedule/schedule/tsconfig.json b/packages/schedule/schedule/tsconfig.json index 3667dd3ce9..caf721d0c9 100644 --- a/packages/schedule/schedule/tsconfig.json +++ b/packages/schedule/schedule/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/time" + }, { "path": "../../workspace/workspace" } diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 2553bb43a2..f5d4e785cd 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { randomUUID } from 'node:crypto' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -456,6 +457,7 @@ describe('HarnessSdkJsonRpcServer', () => { it('forwards whole-agent status without attributing a turn outcome', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const transport = new FakeTransport() const server = new HarnessSdkJsonRpcServer(ctx, transport) diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 95b4ad92c4..5f28fd8f82 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -39,16 +39,6 @@ import { checkpointRecord, projectionCacheDomainSpec } from '../src/spec.ts' import type { CheckpointRecord } from '../src/spec.ts' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'cache-test/marks': MarksState - 'cache-test/secondary-marks': MarksState - 'cache-test/marks2': Map - 'cache-test/count': number - 'cache-test/secret': string - 'cache-test/marks3': MarksState - 'cache-test/summary-excluded': { n: number } - title: string | null - } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } 'cache-test/secondary-marks': { marks: string[] } @@ -162,7 +152,7 @@ async function harness(options: HarnessOptions = {}) { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit(options.stateVersion)) + await ctx.sessionProjections.register(marksUnit(options.stateVersion)) const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, root, fiber, cache: ctx.sessionProjectionCache } } @@ -313,7 +303,7 @@ describe('SessionProjectionCache write policy', () => { await ctx.sessionProjectionCache.write(clean) expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null }) // A unit whose state violates the plain-JSON contract fails the write loud. - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'cache-test/marks2', stateSchema: z.custom>(() => true), init: () => new Map(), @@ -349,7 +339,7 @@ describe('SessionProjectionCache write policy', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) // A directory where the record document must land makes the atomic @@ -400,7 +390,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/secondary-marks': { ver: 1, seq: SessionSeq(6), val: { marks: ['secondary'] } }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(secondaryMarksUnit) + await ctx.sessionProjections.register(secondaryMarksUnit) // The block carries one watermark: the seq every served value has folded // through at least, so the lower of the two rows names the block. @@ -470,7 +460,7 @@ describe('SessionProjectionCache listing read', () => { }, ) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) const seededHeader = { ...headerOf(id), isSeeded: true } // An older format generation never serves the full block, but its title @@ -522,7 +512,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/secret': { ver: 1, seq: SessionSeq(4), val: 'private prompt text' }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(secretUnit) + await ctx.sessionProjections.register(secretUnit) const header = headerOf(SessionId('host-state')) expect(cache.cachedSnapshot(header)).toEqual({ @@ -559,7 +549,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(marks3Unit) + await ctx.sessionProjections.register(marks3Unit) const block = cache.cachedSnapshot(headerOf(SessionId('multi-row'))) expect(block?.values).toEqual({ 'cache-test/marks': { marks: ['a'] }, @@ -765,7 +755,7 @@ describe('SessionProjectionCache cold-read seeding', () => { }) const { cache, ctx } = await harness({ root }) const apply = vi.fn((_state: number, _event: SessionEvent) => 1) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'cache-test/count', stateSchema: z.number().int().nonnegative(), init: () => 0, @@ -807,7 +797,7 @@ describe('SessionProjectionCache cold-read seeding', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) // A directory where the record document must land makes the write-back @@ -832,7 +822,7 @@ describe('SessionProjectionCache summary reads', () => { 'cache-test/summary-excluded': { ver: 1, seq: SessionSeq(1), val: { n: 3 } }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(summaryExcludedUnit) + await ctx.sessionProjections.register(summaryExcludedUnit) const summaryKeys = ctx.sessionProjections.summaryKeys() // The summary watermark follows the served rows only: the marked row sits diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index 2849ea1b81..d20b9a8c73 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -37,9 +37,6 @@ import { checkpointRow, projectionCacheDomainSpec } from '../src/spec.ts' // Declarations must match the shipped title unit's exactly (the repo-wide // compile face sees both). declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - title: string | null - } interface SessionProjectionMap { title: string | null } @@ -110,7 +107,7 @@ async function harness(root: string) { const ctx = await storageHarness(root) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, cache: ctx.sessionProjectionCache } } @@ -324,7 +321,7 @@ describe('archived version recovery', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) // The boot survives the broken record — this line rejecting IS the fixed bug. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 4907213c01..af4c63051f 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -18,14 +18,14 @@ en: 3a15581f0b043e79 zh: 302fc8afe8fb45fa /deepseek-ai-dsh-session-projection/use-this-package/when-to-choose-it: - en: 90b7c26cb45be52a - zh: 386e294ec09607c1 + en: e16fd6f4fcace77c + zh: 4488f7f0b4751ea4 /deepseek-ai-dsh-session-projection/use-this-package/define-a-projection-unit: en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: 6a19c075f826b3f7 - zh: c200277cb58062be + en: b75f8f00df2933e1 + zh: c53e64518953110b /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 3bbc61a438aeabd0 zh: bd1824f4da278764 @@ -39,8 +39,8 @@ en: 8afa0fdb49898f6d zh: 6b2da34455963935 /deepseek-ai-dsh-session-projection/understand-the-implementation/source-map: - en: 01b1a2ad7a7cab97 - zh: bd0436807998dda6 + en: c19f656b022506fa + zh: fdcba0083f08a76b /deepseek-ai-dsh-session-projection/understand-the-implementation/drive-and-checkpoint-flow: en: 00136880bb586403 zh: bd35f645111c068c diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 97c8b4c6ab..77d8a3425f 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -29,7 +29,7 @@ Mount `dsh-session-projection` wherever client carriers need current values of l ### When to choose it -Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. Skip it for host-only bookkeeping that no client reads: a unit without a `wire` block stays host-only. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. +Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. A unit without a `wire` block keeps its state host-only. Domain owners declare `sessionProjections` in their plugin dependencies and await registration before exposing synchronous service methods. Other plugins read those methods through the owning service. ### Define a projection unit @@ -55,14 +55,15 @@ const definition = { ### Register and read -`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. The returned disposer can be yielded into a composite `ctx.effect()` on the same fiber to keep the unit available through dependent cleanup. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. +`await register(definition)` infers the host state type from `stateSchema`, installs the unit, and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. ```text -const dispose = ctx.sessionProjections.register(definition) +const todoProjectionHandle = await ctx.sessionProjections.register(definition) +const state = todoProjectionHandle.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` -A domain that requires projected state declares `sessionProjections` as a Cordis service dependency; optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent. +A domain that owns projected state declares `sessionProjections` as a Cordis service dependency and keeps its handle private. Consumers inject that domain service and use its methods, such as `ctx.agents.turnBoundaryOf(session)`, without importing definitions or retaining projection readers. Optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent. ### Persisted checkpoints @@ -95,7 +96,7 @@ The package is the Service Definition and drive role of a capability seam: the f | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionProjectionRegistry` service, `ProjectionDefinition`, snapshot and checkpoint machinery | -| [`src/types.ts`](src/types.ts) | The merge-extensible `SessionProjectionMap` and `SessionProjectionStateMap` type tables, plus the summary-exclusion mark and its derived view and summary types | +| [`src/types.ts`](src/types.ts) | The merge-extensible client `SessionProjectionMap` type table, plus the summary-exclusion mark and its derived view and summary types | ### Drive and checkpoint flow diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 2d6673979d..2233e5b7c1 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。当维护的是无客户端读取的 host-only 记账时跳过:不带 `wire` 块的单元保持 host-only。host 读取方要么在插件 `inject` 中声明 `sessionProjections`,要么在注册表或必需 key 缺席时明确失败。贡献方可以继续通过 `ctx.inject(['sessionProjections'], ...)` 保持可选注册。 +当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。不带 `wire` 块的单元将状态保留在 host。领域所有者在插件依赖中声明 `sessionProjections`,并等待注册完成后才提供同步服务方法。其他插件通过所属服务调用这些方法。 ### 定义投影单元 @@ -55,14 +55,15 @@ const definition = { ### 注册与读取 -`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。返回的 disposer 可以 yield 到同一 fiber 上的复合 `ctx.effect()` 中,使单元在依赖它的清理完成前保持可用。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 +`await register(definition)` 从 `stateSchema` 推导宿主状态类型,安装单元,并返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 ```text -const dispose = ctx.sessionProjections.register(definition) +const todoProjectionHandle = await ctx.sessionProjections.register(definition) +const state = todoProjectionHandle.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` -必须使用投影状态的领域把 `sessionProjections` 声明为 Cordis 服务依赖;可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。 +持有投影状态的领域将 `sessionProjections` 声明为 Cordis 服务依赖,并在内部保留句柄。消费方注入该领域服务并使用其方法,例如 `ctx.agents.turnBoundaryOf(session)`,无需导入定义或保留投影读取句柄。可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。 ### 持久检查点 @@ -95,7 +96,7 @@ Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectio | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionProjectionRegistry` 服务、`ProjectionDefinition`、快照与检查点机制 | -| [`src/types.ts`](src/types.ts) | 可合并扩展的 `SessionProjectionMap` 与 `SessionProjectionStateMap` 类型表,以及摘要排除标记及其派生的视图与摘要类型 | +| [`src/types.ts`](src/types.ts) | 可合并扩展的 客户端 `SessionProjectionMap` 类型表,以及摘要排除标记及其派生的视图与摘要类型 | ### 驱动与检查点流程 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index b196567ad5..431c9e9800 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -1,6 +1,6 @@ /** - * Service Definition and drive registry for the session-projection capability seam: the merge-extensible state and client-view type - * tables, the `ProjectionDefinition` state-driven computation unit contract, + * Service Definition and drive registry for the session-projection capability seam: the merge-extensible client-view type + * table, the `ProjectionDefinition` state-driven computation unit contract, * and the `ctx.sessionProjections` registry that DRIVES every registered unit * forward eagerly over committed session events. Domain host plugins * contribute pure folds and optional client views; the framework owns the @@ -35,13 +35,11 @@ declare module '@deepseek-ai/cordis' { import type { SessionProjectionMap, - SessionProjectionStateMap, SessionProjectionViewValue, } from './types.ts' export type { SessionProjectionMap, - SessionProjectionStateMap, SessionProjectionSummaryExcluded, SessionProjectionSummaryExcludedKey, SessionProjectionSummaryValues, @@ -49,6 +47,22 @@ export type { SessionProjectionViewValues, } from './types.ts' +/** An effect-owned registration with a reader retained independently of registration. */ +export interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise +} + /** * One domain's state-driven computation unit: a pure synchronous fold plus * declarations and an optional client view — never an opaque getter. The framework drives @@ -57,11 +71,8 @@ export type { * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */ -export interface ProjectionDefinition< - K extends keyof SessionProjectionStateMap, - S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K], -> { - /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ +export interface ProjectionDefinition { + /** The projection key this unit owns. */ key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType @@ -202,8 +213,8 @@ interface UnitCell { } /** - * One live registration: the unit plus its per-session cells (dropped whole - * once the last registrant releases it). + * One shared unit and its per-session cells, retained by acquired readers after + * the final registration leaves the live table. * * `refs` exists because one unit definition already serves every session — the * cells are keyed by `Session` — while registrants are per-session: @@ -232,15 +243,11 @@ function cursorBefore(offset: SessionLogOffset): SessionSeqCursor { * raw result changes by `Object.is`. * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first - * touch (event or read). Registration is an effect (disposer rides the - * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. A host reader either declares - * `sessionProjections` in its plugin `inject` or fails explicitly when the - * registry or required key is absent. Contributors may preserve optional - * registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key - * share one unit and are counted: the same tool package mounted in N agent - * presets registers N times, and the key survives until the last one - * unloads. + * touch (event or read). Consumers declare `sessionProjections` as a required + * injection, await registration during activation, and retain its ready handle. + * Registrants sharing a key share one unit and are counted. The last release + * removes the unit from live drives, checkpoints, and client snapshots; retained + * handles still fold new events on demand using the original unit. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() @@ -271,36 +278,31 @@ export class SessionProjectionRegistry extends Service { } /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ - register< - K extends keyof SessionProjectionMap, - S extends SessionProjectionStateMap[K], - >( + register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, - ): () => void + ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ - register< - K extends Exclude, - S extends SessionProjectionStateMap[K], - >( - definition: Omit, 'wire'>, - ): () => void - register( + register( + definition: ProjectionDefinition & { key: Exclude }, + ): Promise> + async register( definition: ProjectionDefinition, - ): () => void { + ): Promise> { const wire = definition.wire as { viewSchema: ZodType view(state: S): unknown @@ -323,11 +325,13 @@ export class SessionProjectionRegistry extends Service { if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`) } - const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { + let registration!: Registration + const dispose = this.ctx.effect(() => { const key = erased.key const existing = this.registrations.get(key) if (existing === undefined) { - this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) + registration = { def: erased, cells: new WeakMap(), refs: 1 } + this.registrations.set(key, registration) this.summaryKeySet = undefined } else { if (existing.def.stateVersion !== erased.stateVersion) { @@ -337,20 +341,27 @@ export class SessionProjectionRegistry extends Service { throw new Error(`session projection key ${JSON.stringify(key)} is already registered with summary=${String(existing.def.wire?.summary)}; refusing to share it with summary=${String(erased.wire?.summary)}`) } existing.refs += 1 + registration = existing } - yield () => { - const live = this.registrations.get(key) - /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ - if (live === undefined) return - live.refs -= 1 - if (live.refs === 0) { + return () => { + registration.refs -= 1 + if (registration.refs === 0) { this.registrations.delete(key) this.summaryKeySet = undefined } } - }.bind(this), 'sessionProjections.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; keep the Cordis disposer so effects can adopt it. - return dispose + }, 'sessionProjections.register()') + // Observe disposal during the asynchronous registration handoff before exposing a reader. + await Promise.resolve() + this.ctx.fiber.assertActive() + return { + async dispose() { + await dispose() + }, + stateOf: (session) => { + return this.cellFor(registration, session).state as S + }, + } } /** @@ -369,24 +380,6 @@ export class SessionProjectionRegistry extends Service { return () => void dispose() } - /** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ - stateOf( - session: Session, - key: K, - ): SessionProjectionStateMap[K] | undefined { - const registration = this.registrations.get(key) - if (registration === undefined) return undefined - this.materializeCells(session) - return this.cellFor(registration, session).state as SessionProjectionStateMap[K] - } - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/packages/session/session-projection/src/types.ts b/packages/session/session-projection/src/types.ts index ec5ef1d579..1a558a43b5 100644 --- a/packages/session/session-projection/src/types.ts +++ b/packages/session/session-projection/src/types.ts @@ -41,13 +41,6 @@ export type SessionProjectionSummaryExcluded = T & { */ export interface SessionProjectionMap {} -/** - * The merge-extensible host fold-state table. Each client-visible key also - * appears in {@link SessionProjectionMap}; host-only keys appear only here. - * Values must be plain JSON so the projection cache can persist them. - */ -export interface SessionProjectionStateMap {} - /** * One map entry's client view value: the summary mark stripped. The * conditional distributes over a nullable entry's union, so a `| null` member diff --git a/packages/session/session-projection/tests/registration-reader.spec.ts b/packages/session/session-projection/tests/registration-reader.spec.ts new file mode 100644 index 0000000000..6850d1fd86 --- /dev/null +++ b/packages/session/session-projection/tests/registration-reader.spec.ts @@ -0,0 +1,110 @@ +/** Retained readers follow Session events independently of registry participation. */ +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { z } from 'zod' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + 'test/reader': number + } +} + +const definition = { + key: 'test/reader', + stateSchema: z.number(), + stateVersion: 1, + init: () => 0, + apply: (state: number) => state + 1, + wire: { viewSchema: z.number(), view: (state: number) => state }, +} satisfies ProjectionDefinition<'test/reader', number> + +async function setup() { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const owner = await ctx.plugin(Object.assign(function registrationOwner() {}, { inject: ['sessionProjections'] })) + const session = ctx.sessions.create() + return { ctx, owner, session } +} + +describe('retained projection readers', () => { + it.each(['handle', 'fiber'] as const)('reads new events after %s disposal without rejoining snapshots or notifications', async (release) => { + const { ctx, owner, session } = await setup() + const apply = vi.fn(definition.apply) + const handle = await owner.ctx.sessionProjections.register({ ...definition, apply }) + const changed = vi.fn() + ctx.sessionProjections.onChanged(changed) + expect(handle.stateOf(session)).toBe(0) + session.append('turn/start', { turn: 1 }) + expect(handle.stateOf(session)).toBe(1) + expect(changed).toHaveBeenCalledOnce() + if (release === 'handle') await handle.dispose() + else await owner.dispose() + session.append('turn/start', { turn: 2 }) + expect(apply).toHaveBeenCalledTimes(1) + expect(handle.stateOf(session)).toBe(2) + expect(apply).toHaveBeenCalledTimes(2) + expect(changed).toHaveBeenCalledOnce() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + const later = ctx.sessions.create() + later.append('turn/start', { turn: 1 }) + expect(handle.stateOf(later)).toBe(1) + }) + + it('keeps the original unit when the same key is registered with a new version', async () => { + const { ctx, owner, session } = await setup() + const original = await owner.ctx.sessionProjections.register(definition) + expect(original.stateOf(session)).toBe(0) + await original.dispose() + const replacement = await ctx.sessionProjections.register({ ...definition, stateVersion: 2, apply: state => state + 10 }) + session.append('turn/start', { turn: 1 }) + expect(original.stateOf(session)).toBe(1) + expect(replacement.stateOf(session)).toBe(10) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 10 }) + }) + + it('reads only the selected unit without materializing unrelated registrations', async () => { + const { ctx, owner, session } = await setup() + const unrelatedInit = vi.fn(() => 0) + await ctx.sessionProjections.register({ + key: 'test/unrelated-reader', stateSchema: z.number(), stateVersion: 1, + init: unrelatedInit, apply: state => state, + }) + const handle = await owner.ctx.sessionProjections.register(definition) + expect(handle.stateOf(session)).toBe(0) + expect(unrelatedInit).not.toHaveBeenCalled() + }) + + it('rejects initialization when the owner unloads before the handle is returned', async () => { + const { ctx, owner, session } = await setup() + const registration = owner.ctx.sessionProjections.register(definition) + const rejected = expect(registration).rejects.toThrow() + await owner.dispose() + await rejected + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + }) + + it.each(['twice', 'owner-unload'] as const)('releases one reference once under %s disposal', async (release) => { + const { ctx, owner, session } = await setup() + const retained = await ctx.sessionProjections.register(definition) + const handle = await owner.ctx.sessionProjections.register(definition) + if (release === 'twice') { + await expect(handle.dispose()).resolves.toBeUndefined() + await expect(handle.dispose()).resolves.toBeUndefined() + } else { + await Promise.all([owner.dispose(), expect(handle.dispose()).resolves.toBeUndefined()]) + await expect(handle.dispose()).resolves.toBeUndefined() + } + session.append('turn/start', { turn: 1 }) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 1 }) + expect(handle.stateOf(session)).toBe(1) + await retained.dispose() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) + +}) diff --git a/packages/session/session-projection/tests/registration.type-test.ts b/packages/session/session-projection/tests/registration.type-test.ts new file mode 100644 index 0000000000..dc9cadce3b --- /dev/null +++ b/packages/session/session-projection/tests/registration.type-test.ts @@ -0,0 +1,68 @@ +/** Compile-time registration checks for local state inference and client view declarations. */ +import { expectTypeOf } from 'vitest' +import { z } from 'zod' +import type { Session } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition, ProjectionHandle, SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + 'type-test/label': { label: string } + } +} + +const counter = { + key: 'type-test/counter', + stateSchema: z.number(), + stateVersion: 1, + init: () => 0, + apply: state => state + 1, +} satisfies ProjectionDefinition<'type-test/counter', number> + +/** + * Check inference and invalid registrations without executing them. + * @param registry - public projection registry. + * @param session - Session used to typecheck retained reads. + * @returns after the typechecked registration calls. + */ +export async function checkProjectionRegistrationTypes(registry: SessionProjectionRegistry, session: Session): Promise { + const host = await registry.register(counter) + expectTypeOf(host).toEqualTypeOf>() + expectTypeOf(host.stateOf(session)).toEqualTypeOf() + + const nullable = await registry.register({ + key: 'type-test/nullable', + stateSchema: z.string().nullable(), + stateVersion: 1, + init: () => null, + apply: (state) => { + expectTypeOf(state).toEqualTypeOf() + return state + }, + }) + expectTypeOf(nullable.stateOf(session)).toEqualTypeOf() + + const visible = { + ...counter, + key: 'type-test/label' as const, + wire: { + viewSchema: z.object({ label: z.string() }), + view: (state: number) => ({ label: String(state) }), + }, + } + const client = await registry.register(visible) + expectTypeOf(client.stateOf(session)).toEqualTypeOf() + + // @ts-expect-error -- initial state must match the state schema. + void registry.register({ ...counter, init: () => 'wrong' }) + // @ts-expect-error -- fold results must match the state schema. + void registry.register({ ...counter, apply: () => 'wrong' }) + // @ts-expect-error -- a declared client key requires its view. + void registry.register({ ...counter, key: 'type-test/label' }) + + const wrongView = { ...visible, wire: { ...visible.wire, view: (state: number) => ({ label: state }) } } + // @ts-expect-error -- the view must match the client key's declared value. + void registry.register(wrongView) + const unknownView = { ...visible, key: 'type-test/undeclared-view' as const } + // @ts-expect-error -- client-visible keys must be declared in the client map. + void registry.register(unknownView) +} diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index de7ecdde60..589c44fc9f 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -22,13 +22,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'test/marks': MarksState - 'test/count': number - 'test/stable-view': StableViewState - 'test/cut': number - } - interface SessionProjectionMap { 'test/marks': { marks: string[] } 'test/stable-view': { marks: string[] } @@ -165,7 +158,7 @@ describe('SessionProjectionRegistry drive', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(cutUnit()) + const cut = await ctx.sessionProjections.register(cutUnit()) const inherited: SessionEvent[] = [ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, @@ -176,7 +169,7 @@ describe('SessionProjectionRegistry drive', () => { meta: { isSeeded: true }, }) - expect(ctx.sessionProjections.stateOf(session, 'test/cut')).toBe(inherited.length) + expect(cut.stateOf(session)).toBe(inherited.length) const restored = ctx.sessionProjections.restore( {}, inherited, @@ -197,12 +190,12 @@ describe('SessionProjectionRegistry drive', () => { inherited, SessionLogOffset(0), ).asOfSeq).toBe(1) - expect(ctx.sessionProjections.stateOf(prepared, 'test/cut')).toBe(inherited.length) + expect(cut.stateOf(prepared)).toBe(inherited.length) }) it('drives a registered unit over committed events and snapshots the current value', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['a']) mark(session, ['a', 'b']) const snapshot = ctx.sessionProjections.snapshot(session) @@ -213,7 +206,7 @@ describe('SessionProjectionRegistry drive', () => { it('builds the cell lazily from the full log for a unit registered after events flowed', async () => { const { ctx, session } = await harness() mark(session, ['pre-registration']) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['pre-registration'] }) // The lazily-built cell then continues on the live drive path. mark(session, ['after']) @@ -222,7 +215,7 @@ describe('SessionProjectionRegistry drive', () => { it('serves init-derived state and asOfSeq -1 for an empty log', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const snapshot = ctx.sessionProjections.snapshot(session) expect(snapshot.asOfSeq).toBe(-1) expect(snapshot.values['test/marks']).toEqual({ marks: [] }) @@ -230,7 +223,7 @@ describe('SessionProjectionRegistry drive', () => { it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const seen: { key: string; value: unknown; seq: SessionSeq; sessionId: string }[] = [] ctx.sessionProjections.onChanged((changedSession, key, value, seq) => { seen.push({ key, value, seq, sessionId: String(changedSession.id) }) @@ -244,19 +237,19 @@ describe('SessionProjectionRegistry drive', () => { it('does not compute a view while no change listener exists', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + const handle = await ctx.sessionProjections.register(stableViewUnit(view)) session.append('turn/start', { turn: 1 }) session.append('turn/start', { turn: 2 }) - expect(ctx.sessionProjections.stateOf(session, 'test/stable-view')?.revision).toBe(2) + expect(handle.stateOf(session).revision).toBe(2) expect(view).not.toHaveBeenCalled() }) it('publishes the first observed view and suppresses later same-reference views', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + await ctx.sessionProjections.register(stableViewUnit(view)) const seen: unknown[] = [] ctx.sessionProjections.onChanged((_session, key, value) => { @@ -277,7 +270,7 @@ describe('SessionProjectionRegistry drive', () => { it('publishes the first view after an unobserved state change', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + await ctx.sessionProjections.register(stableViewUnit(view)) const first: unknown[] = [] const stop = ctx.sessionProjections.onChanged((_session, key, value) => { if (key === 'test/stable-view') first.push(value) @@ -305,7 +298,7 @@ describe('SessionProjectionRegistry drive', () => { const stateByEvent = new Map() const viewByState = new Map() const computedViews: MarksView[] = [] - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'test/marks', stateSchema: marksViewSchema.nullable(), init: () => initialState, @@ -431,7 +424,7 @@ describe('SessionProjectionRegistry drive', () => { it('drives independently per session (cells are per-session watermarks)', async () => { const { ctx, session } = await harness() const other = ctx.sessions.create() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['one']) mark(other, ['two']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['one'] }) @@ -440,70 +433,71 @@ describe('SessionProjectionRegistry drive', () => { it('updates host-only units without publishing them to wire listeners', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + const count = await ctx.sessionProjections.register(countUnit()) const changedKeys: string[] = [] ctx.sessionProjections.onChanged((_session, key) => { changedKeys.push(key) }) session.append('turn/start', { turn: 1 }) expect(changedKeys).toEqual([]) - expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect(count.stateOf(session)).toBe(1) expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: [] } }) }) it('shares one unit between registrants of the same key', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // One definition already serves every session (cells are keyed by // Session), and registrants are per-session now: an agent preset mounts // the same tool package once per agent. - expect(() => ctx.sessionProjections.register(marksUnit())).not.toThrow() + const handle = await ctx.sessionProjections.register(marksUnit()) + expect(typeof handle.stateOf).toBe('function') mark(session, ['kept']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) }) it('keeps the unit until the last registrant releases it', async () => { const { ctx, session } = await harness() - const first = ctx.sessionProjections.register(marksUnit()) - const second = ctx.sessionProjections.register(marksUnit()) + const first = await ctx.sessionProjections.register(marksUnit()) + const second = await ctx.sessionProjections.register(marksUnit()) mark(session, ['kept']) - first() + await first.dispose() // The regression this counts against: without last-release semantics, one // session ending strips the projection from every other live session, // because the first registrant owns the only disposer. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) - second() + await second.dispose() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) it('refuses to share a key across a stateVersion change', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // The one incompatibility a runtime comparison can name: the versioned // contract says the cached state shape differs, so the two cannot share // cells. Everything else about a definition is functions. - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) - .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) + .rejects.toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) }) it('rejects a non-integer or negative stateVersion at register time', async () => { const { ctx } = await harness() - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/) - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).toThrow(/stateVersion/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).rejects.toThrow(/stateVersion/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).rejects.toThrow(/stateVersion/) }) it('register() disposer removes the key (with its cells) and frees it for re-registration', async () => { const { ctx, session } = await harness() - const dispose = ctx.sessionProjections.register(marksUnit()) + const dispose = await ctx.sessionProjections.register(marksUnit()) mark(session, ['cached']) - dispose() + await dispose.dispose() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // Fresh registration rebuilds from the log, not from a stale cell. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['cached'] }) }) @@ -511,8 +505,8 @@ describe('SessionProjectionRegistry drive', () => { it('removes registrations and change listeners when their owning fiber unloads (HMR safety)', async () => { const { ctx, session } = await harness() const notifications: string[] = [] - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionProjections.register(marksUnit()) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + await inner.sessionProjections.register(marksUnit()) inner.sessionProjections.onChanged((_session, key) => { notifications.push(key) }) @@ -525,38 +519,36 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) - it('keeps a yielded registration live until the composite finishes using it', async () => { + it('retains readable state during owner teardown', async () => { const { ctx, session } = await harness() onTestFinished(async () => { await ctx.fiber.dispose() }) let observed: MarksState | undefined - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.effect(function* () { - yield inner.sessionProjections.register(marksUnit()) - yield () => { observed = inner.sessionProjections.stateOf(session, 'test/marks') } - }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + const handle = await inner.sessionProjections.register(marksUnit()) + inner.effect(() => () => { observed = handle.stateOf(session) }) }, { inject: ['sessionProjections'] })) mark(session, ['live']) await fiber.dispose() expect(observed).toEqual({ marks: ['live'] }) - expect(ctx.sessionProjections.stateOf(session, 'test/marks')).toBeUndefined() + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toBeUndefined() }) it('snapshot serves client views and excludes host-only state', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + const count = await ctx.sessionProjections.register(countUnit()) mark(session, ['a', 'b']) const values = ctx.sessionProjections.snapshot(session).values expect(values['test/marks']).toEqual({ marks: ['a', 'b'] }) expect('test/count' in values).toBe(false) - expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect(count.stateOf(session)).toBe(1) expect('test/unregistered' in values).toBe(false) }) it('checkpoints every persisted unit with its stateVersion and per-cell watermark', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) const markEvent = mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } }) @@ -568,7 +560,7 @@ describe('SessionProjectionRegistry drive', () => { it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) // Hostile (or merely careless) consumer mutates the handed-out state. @@ -582,8 +574,8 @@ describe('SessionProjectionRegistry drive', () => { it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { const { ctx } = await harness() expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) expect(ctx.sessionProjections.restoreFloor({})).toBe(0) // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 // (one below the first needed seq 6), so the read proves seq 5 still exists. @@ -605,8 +597,8 @@ describe('SessionProjectionRegistry drive', () => { it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const tail: SessionEvent[] = [ { type: 'test/mark', seq: SessionSeq(3), time: 3, data: { marks: ['new'] } }, { type: 'turn/end', seq: SessionSeq(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, @@ -639,8 +631,8 @@ describe('SessionProjectionRegistry drive', () => { it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['done'] } }, 'test/count': { ver: 1, seq: SessionSeq(2), val: 3 }, @@ -675,8 +667,8 @@ describe('SessionProjectionRegistry drive', () => { it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const values = ctx.sessionProjections.viewCheckpoint({ 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, 'test/count': { ver: 99, seq: SessionSeq(4), val: 5 }, // mismatched: absent @@ -688,8 +680,8 @@ describe('SessionProjectionRegistry drive', () => { it('viewCheckpoint and restore exclude host-only state while retaining its checkpoint', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, 'test/count': { ver: 1, seq: SessionSeq(4), val: 5 }, @@ -713,7 +705,7 @@ describe('SessionProjectionRegistry drive', () => { it('rejects version-matching rows whose state no longer matches the registered schema', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const drifted = { 'test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: 'not-an-array' } }, } @@ -730,7 +722,7 @@ describe('SessionProjectionRegistry drive', () => { it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/count': { ver: 1, seq: SessionSeq(9), val: 10 } } // The anchored floor sits ON the watermark, so the tail read must return // at least seq 9 from an intact log… @@ -775,7 +767,7 @@ describe('SessionProjectionRegistry drive', () => { it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'test/marks', stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), init: () => null as MarksState, diff --git a/packages/session/session-projection/tests/summary.spec.ts b/packages/session/session-projection/tests/summary.spec.ts index af65cb58ab..de5c3e5659 100644 --- a/packages/session/session-projection/tests/summary.spec.ts +++ b/packages/session/session-projection/tests/summary.spec.ts @@ -18,11 +18,6 @@ import type { import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'test/summary-kept': { text: string } | null - 'test/summary-excluded': number - 'test/summary-excluded-nullable': string[] | null - } interface SessionProjectionMap { 'test/summary-kept': { text: string } | null 'test/summary-excluded': SessionProjectionSummaryExcluded @@ -75,9 +70,9 @@ describe('session projection summary keys', () => { it('derives the summary set from the registered units and their marks', async () => { const { ctx, registry } = await mount() try { - registry.register(keptUnit()) - registry.register(excludedUnit()) - registry.register(excludedNullableUnit()) + await registry.register(keptUnit()) + await registry.register(excludedUnit()) + await registry.register(excludedNullableUnit()) const keys = registry.summaryKeys() expect([...keys]).toEqual(['test/summary-kept']) expect(keys.has('test/summary-excluded')).toBe(false) @@ -91,11 +86,11 @@ describe('session projection summary keys', () => { it('refuses a second registration of one key with a different summary membership', async () => { const { ctx, registry } = await mount() try { - registry.register(keptUnit()) - expect(() => registry.register({ + await registry.register(keptUnit()) + await expect(registry.register({ ...keptUnit(), wire: { ...keptUnit().wire, summary: false }, - })).toThrow(/test\/summary-kept.*already registered with summary=true/) + })).rejects.toThrow(/test\/summary-kept.*already registered with summary=true/) } finally { await ctx.fiber.dispose() } @@ -106,8 +101,8 @@ describe('session projection summary keys', () => { try { const empty = registry.summaryKeys() expect([...empty]).toEqual([]) - const fiber = await ctx.plugin(Object.assign((hostCtx: Context) => { - hostCtx.sessionProjections.register(keptUnit()) + const fiber = await ctx.plugin(Object.assign(async (hostCtx: Context) => { + await hostCtx.sessionProjections.register(keptUnit()) }, { inject: ['sessionProjections'] })) const registered = registry.summaryKeys() expect(registered).not.toBe(empty) @@ -125,9 +120,9 @@ describe('session projection summary keys', () => { it('serves a summary-shaped block through the key-taking checkpoint face', async () => { const { ctx, registry } = await mount() try { - registry.register(keptUnit()) - registry.register(excludedUnit()) - registry.register(excludedNullableUnit()) + await registry.register(keptUnit()) + await registry.register(excludedUnit()) + await registry.register(excludedNullableUnit()) const checkpoint: ProjectionCheckpoint = { 'test/summary-kept': { ver: 1, seq: SessionSeq(3), val: { text: 'kept' } }, 'test/summary-excluded': { ver: 1, seq: SessionSeq(4), val: 7 }, @@ -151,8 +146,8 @@ describe('session projection summary keys', () => { it('serves cached summary cells through the key-taking cached face', async () => { const { ctx, registry } = await mount() try { - registry.register(keptUnit()) - registry.register(excludedUnit()) + await registry.register(keptUnit()) + await registry.register(excludedUnit()) const session = ctx.sessions.create(SessionId('summary-cached'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) @@ -172,8 +167,8 @@ describe('session projection summary keys', () => { it('reports an empty summary set while the complete face still serves only marked units', async () => { const { ctx, registry } = await mount() try { - registry.register(excludedUnit()) - registry.register(excludedNullableUnit()) + await registry.register(excludedUnit()) + await registry.register(excludedNullableUnit()) const session = ctx.sessions.create(SessionId('summary-empty'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) @@ -191,7 +186,7 @@ describe('session projection summary keys', () => { it('keeps the marked nullable entry nullable through the mark', async () => { const { ctx, registry } = await mount() try { - registry.register(excludedNullableUnit()) + await registry.register(excludedNullableUnit()) const before = ctx.sessions.create(SessionId('summary-nullable'), { meta: { cwd: '/workspace' } }) expect(registry.snapshot(before).values['test/summary-excluded-nullable']).toBeNull() } finally { @@ -202,7 +197,7 @@ describe('session projection summary keys', () => { it('drops an unmaterialized cell and a version-mismatched checkpoint row', async () => { const { ctx, registry } = await mount() try { - registry.register(keptUnit()) + await registry.register(keptUnit()) const session = ctx.sessions.create(SessionId('summary-unmaterialized'), { meta: { cwd: '/workspace' } }) expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({ 'test/summary-kept': null, diff --git a/packages/session/session-stats/src/index.ts b/packages/session/session-stats/src/index.ts index 54679b6e78..570a21daaa 100644 --- a/packages/session/session-stats/src/index.ts +++ b/packages/session/session-stats/src/index.ts @@ -23,7 +23,8 @@ export const inject = ['sessionProjections'] * Register the `sessionStats` unit; the registration is an effect on this * plugin's fiber, so unloading removes the key. * @param ctx - registrant context carrying the projection registry. + * @returns when the plugin is ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register(sessionStatsProjectionDefinition) +export async function apply(ctx: Context): Promise { + await ctx.sessionProjections.register(sessionStatsProjectionDefinition) } diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index 48763db8aa..39b8916dd4 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -63,12 +63,6 @@ interface SessionStatsState extends SessionStatsTotals { pendingCalls: Record } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - sessionStats: SessionStatsState - } -} - const sessionStatsSchema = z.object({ turns: z.number().int().nonnegative(), steps: z.number().int().nonnegative(), diff --git a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts index 410e4c2ecf..5dce4f54d5 100644 --- a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts @@ -12,6 +12,7 @@ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' import { anthropicTextEvents, closeMockServers, mockServer } from '../../../llm/llm-pi-ai/tests/mock-server.ts' @@ -56,6 +57,7 @@ async function loadComposition( "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-session-projection'", + "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-session-title'", ' config:', ' fallbackMaxWords: 5', @@ -78,6 +80,7 @@ async function loadComposition( ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-session-title', SessionTitleService], ['@deepseek-ai/dsh-session-title-first-prompt-llm', providerPlugin], ...entryModules, diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts index b76e25a67a..ae57203690 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts @@ -1,3 +1,4 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -22,6 +23,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-prompt title provider with await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts index 35c297eddc..d4d8fa6615 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts @@ -1,10 +1,10 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' @@ -76,7 +76,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -249,7 +249,7 @@ describe('first-prompt LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, TITLE_CONFIG) // Delegate to the real registration while capturing it, to reach the guard // the service never produces. diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index 762f1ba148..1785782cb1 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.i18n.yaml @@ -42,8 +42,8 @@ en: 84f4a3ff08caf847 zh: 9ed3acae2bb2a8b0 /deepseek-ai-dsh-session-title/understand-the-implementation/lifecycle-and-concurrency: - en: 0167ad2951e5ae4d - zh: 0b1df4b7f20eb2f5 + en: 7a4aeb28bde70096 + zh: 6803a7c3054e80a1 /deepseek-ai-dsh-session-title/understand-the-implementation/normalization: en: 0fa22531683cc824 zh: c6379873eb809e76 diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md index 83b0736619..fb91354ec7 100644 --- a/packages/session/session-title/README.md +++ b/packages/session/session-title/README.md @@ -88,6 +88,8 @@ Titles are durable, log-only state: every accepted revision is a `session/title` ### Lifecycle and concurrency +Messages received during activation wait for the service to become ready and request the built-in fallback. Providers register after service readiness; earlier messages and requests do not trigger them retroactively. Activation failure, service disposal, or Session removal prevents the deferred fallback from writing. + Per-session work state tracks a revision counter, an in-flight fallback, and pending and active provider work. A newer user message, provider disposal, session disposal, or explicit refresh aborts older work through an `AbortController`; a completion whose provider, revision, session, or signal is stale cannot append. Explicit refreshes reserve their revision before provider work; overlapping automatic and explicit fallback requests share one session-local in-flight append. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. ### Normalization diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 5e046468f5..f42174730b 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -88,6 +88,8 @@ kind: "package-reference" ### 生命周期与并发 +激活期间收到的消息会等待服务就绪,再请求内置回退标题。提供方在服务就绪后注册;此前的消息和请求不会追溯触发提供方。激活失败、服务卸载或 Session 移除都会阻止延后的回退标题写入。 + 每个会话的工作状态维护一个修订计数器、一个进行中的回退,以及待处理与活跃的提供方工作。较新的用户消息、提供方 dispose(资源释放)、会话 dispose 或显式刷新都会通过 `AbortController` 中止旧工作;提供方、修订、会话或信号已陈旧的完成结果无法追加。显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算。 ### 规范化 diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 6a7e9acdde..86cfce0058 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -17,7 +17,7 @@ import type { } from '@deepseek-ai/dsh-session' import { SessionSeq } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-agent' export type { SessionTitleEventData, @@ -299,13 +299,14 @@ export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleS /** Log-backed title fold plus asynchronous fallback generation. */ export class SessionTitleService extends Service { - static inject = ['sessions', 'sessionProjections'] + static inject = ['sessions', 'sessionProjections', 'agents'] static Config: z = z.object({ fallbackMaxWords: z.number().step(1).min(1).required(), fallbackMaxBytes: z.number().step(1).min(1).required(), maxTitleBytes: z.number().step(1).min(1).required(), }) + private titleInputProjectionHandle!: ProjectionHandle private readonly config: ResolvedConfig private readonly ownerFiber: Fiber private registration: ProviderRegistration | undefined @@ -328,7 +329,10 @@ export class SessionTitleService extends Service { throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes') } this.config = deepFreeze({ ...value }) + } + protected async [Service.init](): Promise { + const ctx = this.ctx ctx.effect(() => async () => { this.lifetime.abort(new Error('session-title service disposed')) if (this.registration !== undefined) this.registration.closing = true @@ -341,29 +345,11 @@ export class SessionTitleService extends Service { this.work.clear() }, 'sessionTitle lifecycle') - ctx.sessionProjections.register(titleProjectionDefinition) - - ctx.sessionProjections.register<'titleInput', TitleInputState>({ - key: 'titleInput', - stateVersion: 3, - stateSchema: titleInputStateSchema, - init: () => EMPTY_TITLE_INPUT, - apply: (state, event) => { - const message = sessionTitleUserMessageOf(event) - if (message === undefined) return state - return { - first: state.first ?? message, - count: state.count + 1, - lastSeq: message.seq, - } - }, - }) - + // oxlint-disable-next-line typescript/no-misused-promises -- session/event reports rejected observer promises without awaiting them. ctx.on('session/event', (session, event) => { switch (event.type) { case 'user/message': - this.onUserMessage(session, event) - break + return this.onUserMessage(session, event) case 'request/header': this.onRequestHeader(session, event) break @@ -381,6 +367,24 @@ export class SessionTitleService extends Service { state.active?.controller.abort(new Error('session disposed during title generation')) this.work.delete(session) }) + + await ctx.sessionProjections.register(titleProjectionDefinition) + + this.titleInputProjectionHandle = await ctx.sessionProjections.register<'titleInput', TitleInputState>({ + key: 'titleInput', + stateVersion: 3, + stateSchema: titleInputStateSchema, + init: () => EMPTY_TITLE_INPUT, + apply: (state, event) => { + const message = sessionTitleUserMessageOf(event) + if (message === undefined) return state + return { + first: state.first ?? message, + count: state.count + 1, + lastSeq: message.seq, + } + }, + }) } /** @@ -502,10 +506,15 @@ export class SessionTitleService extends Service { return dispose } - /** Schedule fallback creation and any provider cadence for one eligible event. */ - private onUserMessage(session: Session, event: Extract): void { - if (!this.serviceActive()) return + /** Schedule current-provider work; activation-time messages only request the fallback. */ + private async onUserMessage(session: Session, event: Extract): Promise { if (event.data.source.kind !== 'user' || sessionTitleUserMessageOf(event) === undefined) return + if (this.ownerFiber.state === FiberState.LOADING) { + await this.ownerFiber.await() + if (this.serviceActive() && this.ctx.sessions.get(session.id) === session) await this.ensureFallback(session) + return + } + if (!this.serviceActive()) return // A user rename pins the title: no automatic revision may override it. if (this.get(session)?.source.kind === 'user') return const registration = this.registration @@ -549,7 +558,7 @@ export class SessionTitleService extends Service { const state = session === undefined ? undefined : this.work.get(session) const pending = state?.pending if (session === undefined || state === undefined || pending === undefined) return - const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary + const boundary = this.ctx.agents.turnBoundaryOf(session).lastStepBoundary const route = session.requestHeader()?.config if (boundary?.kind !== 'start' || route?.provider !== options.provider @@ -730,7 +739,7 @@ export class SessionTitleService extends Service { } private titleInputOf(session: Session): TitleInputState { - return this.ctx.sessionProjections.stateOf(session, 'titleInput') as TitleInputState + return this.titleInputProjectionHandle.stateOf(session) } /** Queue detached service work and retain it through service disposal. */ diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index 20855445a5..be3c4aa7db 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -77,12 +77,6 @@ export interface TitleInputState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Latest logged title text, or null. */ - title: string | null - /** Eligible human title input. */ - titleInput: TitleInputState - } interface SessionProjectionMap { /** * The session's current normalized title — the latest `session/title` diff --git a/packages/session/session-title/tests/initialization.spec.ts b/packages/session/session-title/tests/initialization.spec.ts new file mode 100644 index 0000000000..4bb8181799 --- /dev/null +++ b/packages/session/session-title/tests/initialization.spec.ts @@ -0,0 +1,179 @@ +/** Live title input remains owned while projection registration is pending. */ +import { Context, FiberState } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createUserMessage, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' +import SessionStore, { type Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionTitleService, { SessionTitleProviderId, type SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title' +import { deepFreeze } from '@deepseek-ai/dsh-util-values' +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +const CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 } + +async function setup(key: 'title' | 'titleInput') { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LlmRuntime) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === key) { + entered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + return { ctx, entered: entered.promise, release } +} + +function message(session: Session, text: string) { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +function titleWritten(ctx: Context, session: Session, kind: 'fallback' | 'provider') { + const written = Promise.withResolvers() + ctx.on('session/event', (target, event) => { + if (target === session && event.type === 'session/title' && event.data.source.kind === kind) written.resolve(undefined) + }) + return written.promise +} + +describe('SessionTitleService activation', () => { + it.each(['title', 'titleInput'] as const)('retains the first message while %s registration is pending', async (key) => { + const { ctx, entered, release } = await setup(key) + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + expect(ctx.get('sessionTitle')).toBeUndefined() + const session = ctx.sessions.create() + const written = titleWritten(ctx, session, 'fallback') + const first = message(session, 'First message during activation') + message(session, 'Second message during activation') + + release.resolve(undefined) + await fiber + await written + + expect(ctx.sessionTitle.get(session)).toMatchObject({ + title: 'First message during activation', messageSeqs: [first.seq], source: { kind: 'fallback' }, + }) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) + }) + + it('uses the fallback for input received before a provider registers', async () => { + const { ctx, entered, release } = await setup('titleInput') + const requests: SessionTitleProviderRequest[] = [] + const provider = ctx.inject(['sessionTitle'], (owner) => { + owner.sessionTitle.register({ + id: SessionTitleProviderId('activation'), automatic: 'all-prompts', + async generate(request) { + requests.push(request) + return { title: 'Generated after activation', messageSeqs: request.messages.map(item => item.seq) } + }, + }) + }) + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + const fallbackWritten = titleWritten(ctx, session, 'fallback') + const first = message(session, 'Prompt before provider registration') + const route = { provider: 'main-provider', model: 'main-model' } + session.append('request/header', { header: { config: route }, reason: 'initial' }) + void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...route, sessionId: session.id, messages: session.deriveMessages() }))) + + release.resolve(undefined) + await fiber + await provider + await fallbackWritten + + expect(requests).toEqual([]) + expect(ctx.sessionTitle.get(session)).toMatchObject({ messageSeqs: [first.seq], source: { kind: 'fallback' } }) + + const providerWritten = titleWritten(ctx, session, 'provider') + const later = message(session, 'Prompt after provider registration') + void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...route, sessionId: session.id, messages: session.deriveMessages() }))) + await providerWritten + + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ route, messages: [ + { seq: first.seq, text: 'Prompt before provider registration' }, + { seq: later.seq, text: 'Prompt after provider registration' }, + ] }) + }) + + it('does not title a Session disposed before activation', async () => { + const { ctx, entered, release } = await setup('title') + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + const removed = ctx.sessions.prepare() + const detach = ctx.sessions.enter(removed) + ctx.sessions.announce(removed) + message(removed, 'Removed during activation') + detach() + const live = ctx.sessions.create() + const written = titleWritten(ctx, live, 'fallback') + message(live, 'Still live') + + release.resolve(undefined) + await fiber + await written + + expect(ctx.sessionTitle.get(removed)).toBeUndefined() + expect(ctx.sessionTitle.get(live)?.title).toBe('Still live') + }) + + it.each(['dispose', 'reject'] as const)('does not write a title after activation %s', async (ending) => { + const { ctx, entered, release } = await setup('titleInput') + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + const session = ctx.sessions.create() + message(session, 'Never commit this title') + if (ending === 'dispose') { + const disposal = fiber.dispose() + release.resolve(undefined) + await disposal + } else { + const failed = expect(Promise.resolve(fiber)).rejects.toThrow('registration failed') + release.reject(new Error('registration failed')) + await failed + } + + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toEqual([]) + expect(ctx.get('sessionTitle')).toBeUndefined() + }) + + it('reports one deferred fallback failure and still titles another Session', async () => { + const { ctx, entered, release } = await setup('titleInput') + const warn = vi.spyOn(ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + ctx.on('internal/status', (fiber) => { + if (fiber.runtime?.callback !== SessionTitleService || fiber.state !== FiberState.ACTIVE) return + const get = vi.spyOn(ctx.sessionTitle, 'get').mockImplementationOnce(() => { throw new Error('title read failed') }) + onTestFinished(() => { get.mockRestore() }) + }) + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + message(ctx.sessions.create(), 'Failed title read') + const live = ctx.sessions.create() + const written = titleWritten(ctx, live, 'fallback') + message(live, 'Unaffected title') + release.resolve(undefined) + await fiber + await written + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener rejected: Error: title read failed')) + expect(ctx.sessionTitle.get(live)?.title).toBe('Unaffected title') + }) +}) diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index dd85dd1376..4edfdd5579 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title' @@ -68,6 +69,7 @@ describe('session title persistence round trips', () => { const writer = new Context() await writer.plugin(SessionStore) await writer.plugin(SessionProjectionRegistry) + await writer.plugin(AgentRegistry) await writer.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await writer.plugin(SessionTitleService, CONFIG) await appendPersistedTitle(writer, id) diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index df2b5f1f91..aa39739120 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService from '@deepseek-ai/dsh-session-title' @@ -12,6 +13,7 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withTitleService) await ctx.plugin(SessionTitleService, CONFIG) return { ctx, session: ctx.sessions.create(SessionId('titled')) } } @@ -90,10 +92,8 @@ describe('title projection unit', () => { } await ctx.plugin(SessionTitleService, CONFIG) - const state = ctx.sessionProjections.stateOf(session, 'titleInput') - expect(state?.count).toBe(5_000) - expect(state?.first?.text).toBe('message 0') - expect(state?.lastSeq).toBe(session.seq - 1) + const state = ctx.sessionProjections.checkpoint(session).titleInput?.val + expect(state).toMatchObject({ count: 5_000, first: { text: 'message 0' }, lastSeq: session.seq - 1 }) expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined() }) diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 8f22a930fc..f7e7830048 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -1,10 +1,10 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished, vi } from 'vitest' import LlmRuntime, { createUserMessage, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { SessionTitleProviderId, type SessionTitleProvider, @@ -56,6 +56,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const parent = ctx.sessions.create(SessionId('title-parent')) parent.append('turn/start', { @@ -121,6 +122,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] const provider: SessionTitleProvider = { @@ -185,6 +187,7 @@ describe('SessionTitleService Provider lifecycle', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 24 }) const currentTitles: (SessionTitleSnapshot | undefined)[] = [] ctx.sessionTitle.register({ @@ -214,6 +217,7 @@ describe('SessionTitleService Provider lifecycle', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const currentTitles: (SessionTitleSnapshot | undefined)[] = [] ctx.sessionTitle.register({ @@ -255,6 +259,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('chunked-provider-input')) session.append('turn/start', { turn: 1 }) @@ -284,6 +289,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -320,6 +326,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const pending = deferred() let observedSignal: AbortSignal | undefined @@ -372,6 +379,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const pending = deferred() const dispose = ctx.sessionTitle.register({ @@ -407,6 +415,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const firstResult = deferred() const requests: SessionTitleProviderRequest[] = [] @@ -452,7 +461,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -508,7 +517,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ title: 'Unexpected title', @@ -541,6 +550,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const provider: SessionTitleProvider = { diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 9654c18edc..184c03df88 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -34,6 +35,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-accept')) session.append('turn/start', { turn: 1 }) @@ -60,6 +62,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-reject')) expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/) @@ -72,6 +75,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Provider title', @@ -110,6 +114,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-unpin-fallback')) session.append('turn/start', { turn: 1 }) @@ -132,6 +137,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) // The provider parks on a test-held deferred so rename lands while its // generation is ACTIVE (not merely scheduled). @@ -172,6 +178,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the // re-derived fallback is empty, so the pinned title survives the refresh. await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 }) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index c6e0dc93c3..644b6074f9 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -31,6 +32,7 @@ async function setup(config: Config = CONFIG): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, config) return ctx } @@ -244,6 +246,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise } = {} ctx.on('internal/plugin', (subject) => { if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return @@ -277,6 +280,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const session = startSession(ctx, 'service-unload-started-fallback') @@ -293,6 +297,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const result = deferred() const requests: SessionTitleProviderRequest[] = [] diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index 1349c52d4b..43d439bb06 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -3,6 +3,7 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -48,6 +49,7 @@ describe('SessionTitleService', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('fresh')) session.append('turn/start', { @@ -85,6 +87,7 @@ describe('SessionTitleService', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('prefixed-title')) session.append('user/message', createUserMessage({ @@ -113,6 +116,7 @@ describe('SessionTitleService', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('eligibility')) session.append('turn/start', { diff --git a/packages/session/session-turn-outline/src/index.ts b/packages/session/session-turn-outline/src/index.ts index 0105e7c186..a5dd6f2198 100644 --- a/packages/session/session-turn-outline/src/index.ts +++ b/packages/session/session-turn-outline/src/index.ts @@ -23,7 +23,8 @@ export const inject = ['sessionProjections'] * Register the `turnOutline` unit; the registration is an effect on this * plugin's fiber, so unloading removes the key. * @param ctx - registrant context carrying the projection registry. + * @returns when the plugin is ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register(turnOutlineProjectionDefinition) +export async function apply(ctx: Context): Promise { + await ctx.sessionProjections.register(turnOutlineProjectionDefinition) } diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts index b6ffcea3dd..d7d50d3c32 100644 --- a/packages/session/session-turn-outline/src/types.ts +++ b/packages/session/session-turn-outline/src/types.ts @@ -39,10 +39,6 @@ export interface TurnOutlineState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Whole-log turn outline fold state (entries plus the open turn's response draft). */ - turnOutline: TurnOutlineState - } interface SessionProjectionMap { /** * Every started turn with its `turn/start` seq and bounded previews, diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index c01acf8aa5..8c1d26bc96 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -97,7 +97,7 @@ describe('turn outline projection unit', () => { appendPrompt(session, 'prompt') appendAssistant(session, 1, 1, 'streamed but unsettled') expect(outlineOf(ctx, session)[0]?.response).toBe('') - expect(ctx.sessionProjections.stateOf(session, 'turnOutline')?.draft).toBe('streamed but unsettled') + expect(ctx.sessionProjections.checkpoint(session).turnOutline?.val).toMatchObject({ draft: 'streamed but unsettled' }) endTurn(session, 1) expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled') }) diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 7346feb274..8664a8aca1 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -11,7 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -45,11 +45,6 @@ declare module '@deepseek-ai/dsh-session-projection/types' { /** Recorded current directory for Session observations, or null when none is recorded. */ workingDirectory: string | null } - - interface SessionProjectionStateMap { - /** Last committed working directory, or null before an explicit value exists. */ - workingDirectory: string | null - } } /** Deployment default for Sessions without an original project directory. */ @@ -72,14 +67,19 @@ export class WorkingDirectoryService extends Service { private readonly operations = new Set>() private readonly lifecycle = new AbortController() + private workingDirectoryProjectionHandle!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'workingDirectory') const directory = config.defaultDirectory ?? process.cwd() if (!isAbsolute(directory)) throw new Error('working-directory: defaultDirectory must be absolute') this.defaultDirectory = directory + } + protected async [Service.init](): Promise { + const ctx = this.ctx const directorySchema = zod.string().min(1).refine(isAbsolute).nullable() - ctx.sessionProjections.register({ + this.workingDirectoryProjectionHandle = await ctx.sessionProjections.register({ key: 'workingDirectory', stateVersion: 1, stateSchema: directorySchema, @@ -120,7 +120,7 @@ export class WorkingDirectoryService extends Service { * @returns its effective absolute directory. */ get(session: Session): string { - return this.ctx.sessionProjections.stateOf(session, 'workingDirectory') ?? this.defaultDirectory + return this.workingDirectoryProjectionHandle.stateOf(session) ?? this.defaultDirectory } /** @@ -139,7 +139,7 @@ export class WorkingDirectoryService extends Service { control.throwIfAborted() agent.ctx.fiber.assertActive() if (info?.type === 'directory') { - if (this.ctx.sessionProjections.stateOf(agent.session, 'workingDirectory') === null) { + if (this.workingDirectoryProjectionHandle.stateOf(agent.session) === null) { agent.session.append('working-directory/change', { cwd: current }) } return current diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index d3507373c7..a094cd0d13 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -307,6 +308,7 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) @@ -715,6 +717,7 @@ describe('tool-bash-persistent', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index a4c6cd41de..c2e5db5201 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -9,6 +9,7 @@ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -37,6 +38,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) @@ -424,6 +426,7 @@ describe('foreground commands as jobs', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 0b01d7c81b..9a95c328f9 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -11,7 +11,6 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { SessionId } from '@deepseek-ai/dsh-session' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolJobs from '@deepseek-ai/dsh-tool-jobs' @@ -51,6 +50,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -66,6 +66,7 @@ async function setupWithJobs() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -214,11 +215,10 @@ async function setupSandboxed(withApproval = false) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) - await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) @@ -507,6 +507,7 @@ describe('bash tool', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(LocalSubprocessRuntime) @@ -526,6 +527,7 @@ describe('the background surface follows the job registry', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -637,6 +639,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -667,6 +670,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(CountingStartExecutor) @@ -1337,6 +1341,7 @@ describe('the model-facing bash tool builds its request from named args only (no provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index b2b6d96137..a3c434abb6 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -321,6 +322,7 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) @@ -792,6 +794,7 @@ describe('tool-pwsh-persistent', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 59b99b965c..bb28376873 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -6,6 +6,7 @@ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -86,6 +87,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 5 }) await ctx.plugin(ToolTasks) @@ -456,6 +458,7 @@ describe('foreground commands as jobs (pwsh)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(BashEnvPlugin) @@ -494,6 +497,7 @@ describe('foreground commands as jobs (pwsh)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 66920cc9e9..abe3bf1bf3 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -29,7 +29,6 @@ import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' @@ -152,6 +151,7 @@ async function setup(toolConfig: Partial = {}, dshHome?: string provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(FakeBash) @@ -166,6 +166,7 @@ async function setupWithJobs(toolConfig: Partial = {}, dshHome? provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -227,15 +228,14 @@ async function setupSandboxed(withApproval = false) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) await ctx.plugin(BashEnvPlugin) - await ctx.plugin(SessionProjectionRegistry) // The loop's turnBoundary unit (the open-turn fold) is not mounted in this // bench — the loop itself is not composed. Register its open-turn fold so // the approval service's turn-enclosure gate reads the seeded log shape. - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) if (withApproval) await ctx.plugin(ApprovalService) @@ -618,6 +618,7 @@ describe('per-call sandbox policy resolution', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(ConfiningFakeBash) @@ -914,6 +915,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(BashEnvPlugin) @@ -933,6 +935,7 @@ describe('the background surface follows the job registry', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakeBash) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index e3362cda0a..fc4cc6bd89 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -13,6 +13,7 @@ import { import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -56,6 +57,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillRegistry) @@ -794,6 +797,7 @@ describe('dsh-tool-skill', () => { provideWorkingDirectoryFixture(ctx, home) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index c9418f1da1..ffff21a7b9 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -9,6 +9,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import z from '@deepseek-ai/schemastery' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -302,6 +303,7 @@ it('rejects a payload whose Node.js or pnpm version is malformed', async () => { async function tool(config: workspaceDependencies.Config) { const ctx = new Context() try { + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index fcc020ba73..d3e3a677f8 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -877,6 +877,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('requires an explicit absolute Harness home for nested dsh runtimes', async () => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', @@ -897,6 +898,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { ])('rejects an invalid $field at load', async ({ field, override }) => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 5e077a955e..8d030e2291 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -18,8 +18,8 @@ en: 924b8c15f9529028 zh: 9fb0c399b5a79e3e /deepseek-ai-dsh-subagent/use-this-package/enabling-delegation: - en: 388ae5427c869173 - zh: aff830f4986cf1b3 + en: 025605eddae02478 + zh: 5659cba4340735d8 /deepseek-ai-dsh-subagent/use-this-package/delegation-settings: en: 86c982223fab1a3a zh: 3d220d8ed84eab9a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 93c21b5b88..896d7b7687 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,7 +31,7 @@ This package is the contract every delegation setup shares. You enable it by mou ### Enabling delegation -Mount the service with a provider and the delegation tool. The provider registers under its configured name; each tool row selects one provider. A minimal setup: +Mount the service with `@deepseek-ai/dsh-session-projection`, a provider, and the delegation tool; the service waits for the projection registry before initializing. The provider registers under its configured name; each tool row selects one provider. A minimal setup: ```yaml - name: '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 5e8553bac9..14a20e33d5 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" ### 启用委派 -挂载服务、提供方与委派工具。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下: +一起挂载本服务、`@deepseek-ai/dsh-session-projection`、提供方与委派工具;本服务等待投影注册表可用后才初始化。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下: ```yaml - name: '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 47f1908787..0ee71b497d 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -94,9 +94,6 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, - "@deepseek-ai/dsh-session-projection": { - "optional": true - }, "@deepseek-ai/dsh-session-query": { "optional": true }, diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index d640c8f6b8..416625c594 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -97,12 +97,6 @@ const stateSchema: z.ZodType = z.object({ head: chunkedListSchema(eventDataSchema).optional(), }).strict() -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - subagentCatalog: SubagentCatalogState - } -} - /** * Materialize complete and unknown-mode child identities from parent catalog events. * @param state - parent catalog fold state. diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 119cc26a6a..fd30901a6c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -29,7 +29,7 @@ */ import type { Volatile } from '@deepseek-ai/cordis' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-working-directory' import type {} from '@deepseek-ai/dsh-attachment' @@ -196,7 +196,7 @@ export class SubagentRuntime extends TypertRemoteService { maxDepth: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(1).volatile(), maxActiveSubagents: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(8).volatile(), }) - static inject = ['workingDirectory'] + static inject = ['workingDirectory', 'sessionProjections'] private providers = new Map() private manager: SubagentManager | undefined /** @@ -224,16 +224,18 @@ export class SubagentRuntime extends TypertRemoteService { if (this.manager === manager) this.manager = undefined }, 'subagents.managerBinding()') }) - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(subagentCatalogProjectionDefinition) - projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) - projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) - }) // Archive admission: this runtime is the owner that knows which live // children descend from a Session and how a parent stops them. ctx.inject(['agents'], (agentsCtx: Context) => { installSubagentArchiveAdmission(agentsCtx) }) } + protected async [Service.init](): Promise { + const { sessionProjections } = this.ctx + await sessionProjections.register(subagentCatalogProjectionDefinition) + await sessionProjections.register(subagentTimingProjectionDefinition) + await sessionProjections.register(subagentIdentityProjectionDefinition) + } + /** * Resolve a delegation tool's depth policy against the current user setting. * @param configured - Explicit tool limit, or provider-managed for external delegation. diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 541721f2c1..bc283d911b 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -50,13 +50,6 @@ const timingStateSchema: z.ZodType = z.object({ lastTurnCompleted: z.boolean().optional(), }).strict() -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - subagentTiming: TimingState - subagent: IdentityState - } -} - /** * Fold turn boundaries around the child's own durable descriptor. * diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index c242f1c423..ac576639fa 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -79,7 +79,7 @@ describe('subagent catalog projection', () => { try { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) const parent = ctx.sessions.create(header.id) const changes = vi.fn() ctx.sessionProjections.onChanged(changes) @@ -196,7 +196,7 @@ describe('subagent catalog projection', () => { try { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) const events = [ fact(0, 'valid-child', 0, { mode: 'one-shot' }), { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, data } as unknown as SessionEvent, diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index df8542f988..ba2613febf 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -80,7 +80,7 @@ function policyEvents(events: readonly SessionEvent[]) { } function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { - return ctx.sessionProjections.stateOf(Session.create(id, events), 'sandboxMode') + return ctx.sandboxPolicy.overrideOf(Session.create(id, events)) ?? null } function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index afe176af9b..d87bd25242 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1,3 +1,4 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -100,6 +101,7 @@ async function setupWith( await ctx.plugin(AgentLoop, { agents: [] }) if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) subagentConfigs.set(ctx, await liveConfig(ctx, SubagentRuntime, options.maxActiveSubagents === undefined ? {} : { maxActiveSubagents: options.maxActiveSubagents })) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) @@ -262,6 +264,7 @@ describe('continuable activation capacity', () => { const ctx = new Context() try { await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SubagentRuntime, { maxActiveSubagents })).rejects.toThrow() } finally { await ctx.fiber.dispose() @@ -272,6 +275,7 @@ describe('continuable activation capacity', () => { const ctx = new Context() try { await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const live = await liveConfig(ctx, SubagentRuntime, { maxDepth: 4 }) subagentConfigs.set(ctx, live) const fiber = live.fiber @@ -3802,6 +3806,7 @@ describe('continuable errors', () => { }) await ctx.plugin(AgentLoop, { agents: [] }) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const serviceFiber = await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 9377bbb8fc..90375e00c3 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -1,3 +1,4 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' // The browser-facing prompt and interrupt controls and their stable failure // codes. Session Controller owns catalog observation and transport. @@ -34,6 +35,7 @@ const IMAGE_REF = { attachmentId: 'att', mediaType: 'image/png', bytes: 2, width async function bench(live?: Record) { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) if (live !== undefined) { ctx.provide('agents', { diff --git a/packages/subagent/subagent/tests/external-activation-helpers.ts b/packages/subagent/subagent/tests/external-activation-helpers.ts index 389ce804d6..8ac5f26057 100644 --- a/packages/subagent/subagent/tests/external-activation-helpers.ts +++ b/packages/subagent/subagent/tests/external-activation-helpers.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' import { onTestFinished } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -18,6 +19,7 @@ function parentStore(ctx: Context): Promise> { onTestFinished(() => ctx.fiber.dispose()) const ready = (async () => { if (ctx.get('sessions') === undefined) await ctx.plugin(SessionStore) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) if (ctx.get('agents') === undefined) await ctx.plugin(AgentRegistry) return new WeakMap() })() diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 053bcdf7dc..2e64d9124f 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1,3 +1,4 @@ +import { subagentCatalogProjectionDefinition } from '../src/catalog.ts' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' @@ -49,6 +50,7 @@ async function setup( await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -191,6 +193,7 @@ describe('SubagentRuntime.listChildren', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const ancestor = ctx.sessions.create(SessionId('catalog-ancestor')) ancestor.append('turn/start', { turn: 1 }) @@ -221,6 +224,7 @@ describe('SubagentRuntime.listChildren', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const parent = ctx.sessions.create(SessionId('chunk-parent')) for (let index = 0; index < 1_001; index += 1) { @@ -235,7 +239,8 @@ describe('SubagentRuntime.listChildren', () => { }) } const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions') - const state = ctx.sessionProjections.stateOf(parent, 'subagentCatalog') + const projection = await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + const state = projection.stateOf(parent) const chunkLengths: number[] = [] for (let chunk = state?.head; chunk !== undefined; chunk = chunk.previous) { chunkLengths.push(chunk.values.length) @@ -253,6 +258,7 @@ describe('SubagentRuntime.listChildren', () => { const withoutProjection = new Context() await withoutProjection.plugin(SessionStore) await mountWorkingDirectoryFixture(withoutProjection) + await withoutProjection.plugin(SessionProjectionRegistry) await withoutProjection.plugin(SubagentRuntime) const parent = withoutProjection.sessions.create(SessionId('parent')) await expect(withoutProjection.subagents.listChildren(parent.id)).rejects.toMatchObject({ @@ -441,6 +447,7 @@ describe('SubagentRuntime.listDescendants', () => { it('fails before reading when the Session store or query service is absent', async () => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.subagents.listDescendants(SessionId('root'))).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) await ctx.plugin(SessionStore) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index fb105074c8..bfffcbcf3b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -76,8 +76,6 @@ const contexts = new WeakMap() async function service(withAgents = true): Promise<{ ctx: Context; subagents: SubagentRuntime }> { const ctx = new Context() - // The registry is a required injection of SubagentRuntime (its projection - // units register in the constructor). if (withAgents) { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) @@ -125,7 +123,7 @@ describe('SubagentRuntime', () => { await fiber.dispose() - expect(ctx.sessionProjections.stateOf(parent, 'subagentCatalog')).toBeUndefined() + expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toBeUndefined() }) it('registers, lists, looks up, starts, and removes providers', async () => { diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index 40b21431ec..dd8af27c69 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -38,12 +38,14 @@ describe('subagent timing projection', () => { expect(subagentIdentityProjectionDefinition.wire.view(state)).toEqual({ mode: 'one-shot', seq: 1 }) }) - it('registers with the optional session projection registry', async () => { + it('waits for the session projection registry and releases its projections on unload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) await mountWorkingDirectoryFixture(ctx) - const serviceFiber = await ctx.plugin(SubagentRuntime) + const pending = ctx.plugin(SubagentRuntime) + expect(ctx.get('subagents')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + const serviceFiber = await pending const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values expect(before.subagentTiming).toEqual({ settledMs: 0 }) diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index c9fda66bd0..3b05b61f13 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -27,8 +27,8 @@ en: 1950ef92bab0f46e zh: 593f5f64ed9b5631 /deepseek-ai-dsh-tool-subagent/use-this-package/selecting-a-child-llm: - en: 628ec29adbe175e6 - zh: 82df946ca7eef167 + en: 5cf892e4acab3f38 + zh: c282b79620bc3de5 /deepseek-ai-dsh-tool-subagent/understand-the-implementation: en: 6ce5f749256a7e6c zh: a4f2adb04a70d9cf diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5d61e014e3..d9757eae9a 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -74,7 +74,7 @@ Compose persona and tools in the [preset plugin list](../../preset/agent-preset- ### Selecting a child LLM -Set `modelSelection: true` to expose optional `provider`, `model`, and `reasoning_effort` fields; instances without it reject selection fields. Set `modelDiscovery: true` on one instance per tool scope to register `list_subagent_models`; it defaults off independently of `modelSelection`. A second discovery owner in the same scope fails to register. Discovery follows its owning plugin instance, remains available when its delegation provider is absent, and is removed when that instance unloads. Web and Desktop presets enable discovery and route fields on spawn and leave both disabled on fork. The Host’s `/model-selection-settings` service samples the user preference during Agent creation, independently of tool installation. When enabled, its exact route list is recorded in each fresh top-level Session, inherited by child Sessions, and preserved on restore; later settings edits affect only new Sessions. Without a recorded Session policy, discovery and explicit selection permit only the parent Agent’s current effective provider/model and its supported efforts. Route fields require a backend that advertises `agentOptions`; in-process Spawn and Fork and DSH SDK qualify, while ACP, Codex, and Claude Code fail the mount. +Set `modelSelection: true` to expose optional `provider`, `model`, and `reasoning_effort` fields; instances without it reject selection fields. Set `modelDiscovery: true` on one instance per tool scope to register `list_subagent_models`; it defaults off independently of `modelSelection`. A second discovery owner in the same scope fails to register. Discovery follows its owning plugin instance, remains available when its delegation provider is absent, and is removed when that instance unloads. Web and Desktop presets enable discovery and route fields on spawn and leave both disabled on fork. The Host’s `/model-selection-settings` service samples the user preference during Agent creation, independently of tool installation. Its creation listener subscribes before projection registration completes and awaits the handle; registration failure or owner disposal rejects Agent publication. When enabled, its exact route list is recorded in each fresh top-level Session, inherited by child Sessions, and preserved on restore; later settings edits affect only new Sessions. Without a recorded Session policy, discovery and explicit selection permit only the parent Agent’s current effective provider/model and its supported efforts. Route fields require a backend that advertises `agentOptions`; in-process Spawn and Fork and DSH SDK qualify, while ACP, Codex, and Claude Code fail the mount. A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index b42f3fe2d8..6e41531706 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -74,7 +74,7 @@ kind: "package-reference" ### 选择子级 LLM -设置 `modelSelection: true` 后,该实例公开可选的 `provider`、`model` 和 `reasoning_effort` 字段;未设置的实例拒绝选择字段。在每个工具作用域的一个实例上设置 `modelDiscovery: true`,以注册 `list_subagent_models`;该选项独立于 `modelSelection`,默认关闭。同一作用域的第二个发现工具所有者会注册失败。发现工具随其所属插件实例存续,在委派提供方缺席时仍然可用,并在该实例卸载时移除。Web 与 Desktop preset 为 spawn 启用发现和路由字段,为 fork 保持两者关闭。Host 的 `/model-selection-settings` 服务在 Agent 创建时读取用户偏好,与工具安装无关。启用时,精确路由列表记录在每个新顶层 Session 中,由子 Session 继承,并在恢复时保留;之后的设置编辑只影响新 Session。没有已记录 Session 策略时,发现和显式选择只允许父 Agent 当前生效的 provider/model 及其支持的强度。路由字段要求后端公布 `agentOptions`;进程内 Spawn、Fork 和 DSH SDK 满足条件,ACP、Codex 和 Claude Code 会使挂载失败。 +设置 `modelSelection: true` 后,该实例公开可选的 `provider`、`model` 和 `reasoning_effort` 字段;未设置的实例拒绝选择字段。在每个工具作用域的一个实例上设置 `modelDiscovery: true`,以注册 `list_subagent_models`;该选项独立于 `modelSelection`,默认关闭。同一作用域的第二个发现工具所有者会注册失败。发现工具随其所属插件实例存续,在委派提供方缺席时仍然可用,并在该实例卸载时移除。Web 与 Desktop preset 为 spawn 启用发现和路由字段,为 fork 保持两者关闭。Host 的 `/model-selection-settings` 服务在 Agent 创建时读取用户偏好,与工具安装无关。创建监听器在投影注册完成前订阅并等待句柄;注册失败或所属插件卸载会使 Agent 发布失败。启用时,精确路由列表记录在每个新顶层 Session 中,由子 Session 继承,并在恢复时保留;之后的设置编辑只影响新 Session。没有已记录 Session 策略时,发现和显式选择只允许父 Agent 当前生效的 provider/model 及其支持的强度。路由字段要求后端公布 `agentOptions`;进程内 Spawn、Fork 和 DSH SDK 满足条件,ACP、Codex 和 Claude Code 会使挂载失败。 一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index fa5f83c114..1e27188936 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -145,6 +145,7 @@ function providerWording(inheritsConversation: boolean): { description: string; * Standing preset choices are validated for each Agent before its tool is installed. * @param ctx - Context that owns the registrations. * @param config - delegation-tool configuration. + * @returns when the plugin is ready. */ export async function apply(ctx: Context, config: Config): Promise { for (const field of ['persona', 'toolFilter']) { @@ -162,7 +163,7 @@ export async function apply(ctx: Context, config: Config): Promise { const toolName = config.toolName ?? 'subagent' const modelSelectionEnabled = config.modelSelection === true - ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + const subagentModelSelectionProjectionHandle = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { presets.assertProvider(subagentProvider) @@ -192,7 +193,7 @@ export async function apply(ctx: Context, config: Config): Promise { const initialProvider = ctx.subagents.getProvider(config.provider) if (initialProvider !== undefined) assertSubagentProviderConfiguration(initialProvider) - if (config.modelDiscovery === true) registerListSubagentModels(ctx) + if (config.modelDiscovery === true) registerListSubagentModels(ctx, subagentModelSelectionProjectionHandle) const install = (runtimeCtx: Context): void => { // Load order and HMR replacement can change provider availability while @@ -272,7 +273,7 @@ export async function apply(ctx: Context, config: Config): Promise { throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') } - const routes = subagentModelSelectionPolicy(runtimeCtx.sessionProjections, parent.session) + const routes = subagentModelSelectionPolicy(subagentModelSelectionProjectionHandle, parent.session) const modelSelectionPolicy = routes === undefined ? undefined : { routes } const agentPreset = presets.resolve(args as PresetSelectionRequest) const modelRequest = args as DelegationModelRequest diff --git a/packages/subagent/tool-subagent/src/list-models.ts b/packages/subagent/tool-subagent/src/list-models.ts index 6fd895f3de..a0ad4cc7ac 100644 --- a/packages/subagent/tool-subagent/src/list-models.ts +++ b/packages/subagent/tool-subagent/src/list-models.ts @@ -6,7 +6,8 @@ import type { LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import { parentAgentOptionsForDelegation } from '@deepseek-ai/dsh-subagent' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type { ModelSelectionPolicy } from './model-selection.ts' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import type { AllowedModelRoute, ModelSelectionPolicy } from './model-selection.ts' import { subagentModelSelectionPolicy } from './model-selection-state.ts' interface ListSubagentModelsRequest { @@ -38,11 +39,12 @@ function modelLine(provider: string, model: { id: string; name: string; descript /** Read the requested provider, allowed models, or exact-model efforts. */ async function listSubagentModels( ctx: Context, + policyHandle: ProjectionHandle, request: ListSubagentModelsRequest, exec: ToolRunContext, ): Promise { if (exec.agent === undefined) throw new Error('list_subagent_models requires a calling agent') - const routes = subagentModelSelectionPolicy(ctx.sessionProjections, exec.agent.session) + const routes = subagentModelSelectionPolicy(policyHandle, exec.agent.session) let policy = routes === undefined ? undefined : { routes } const selectionEnabled = policy !== undefined if (policy === undefined) { @@ -97,9 +99,13 @@ async function listSubagentModels( /** * Register discovery of the calling Session’s authorized child models. - * @param ctx - Context carrying the tool registry and Session projections. + * @param ctx - Context carrying the tool registry and LLM service. + * @param policyHandle - retained reader for the Session’s authorized child routes. */ -export function registerListSubagentModels(ctx: Context): void { +export function registerListSubagentModels( + ctx: Context, + policyHandle: ProjectionHandle, +): void { ctx.tools.register(defineTool({ name: 'list_subagent_models', description: @@ -121,7 +127,7 @@ export function registerListSubagentModels(ctx: Context): void { render: (_args, result) => [{ type: 'text', text: result }], }, execute(args, exec) { - return listSubagentModels(ctx, args, exec) + return listSubagentModels(ctx, policyHandle, args, exec) }, })) } diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index a7954c3850..99c806fa36 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -49,15 +49,21 @@ export class SubagentModelSelectionConfig extends Service { constructor(ctx: Context, private config: Config) { super(ctx, 'subagentModelSelection') - ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) - ctx.on('agent/created', ({ agent }) => { + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + const policyReady = ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + ctx.on('agent/created', async ({ agent }) => { + const policy = await policyReady + ctx.fiber.assertActive() const target = agent.session - if (subagentModelSelectionPolicy(ctx.sessionProjections, target) !== undefined) return + if (subagentModelSelectionPolicy(policy, target) !== undefined) return let allowedModels: AllowedModelRoute[] | undefined const parentId = target.header.origin === 'subagent' ? target.header.parentSession : undefined if (parentId !== undefined) { const parent = ctx.sessions.get(parentId) - if (parent !== undefined) allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, parent) + if (parent !== undefined) allowedModels = subagentModelSelectionPolicy(policy, parent) } else if (target.firstLiveSeq === 0 // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed') { @@ -66,6 +72,7 @@ export class SubagentModelSelectionConfig extends Service { } if (allowedModels !== undefined) target.append('subagent/model-selection-policy', { allowedModels }) }) + await policyReady } /** diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index 26516749bd..d1a6b9a9d3 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -2,7 +2,7 @@ import { z as zod } from 'zod' import type { Session } from '@deepseek-ai/dsh-session' -import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { assertAllowedModelRoutes, type AllowedModelRoute } from './model-selection.ts' @@ -21,13 +21,6 @@ declare module '@deepseek-ai/dsh-session/types' { } } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Exact routes authorized for child LLM selection, or null for the parent route. */ - subagentModelSelectionPolicy: AllowedModelRoute[] | null - } -} - const modelSelectionPolicySchema: zod.ZodType = zod.array(zod.object({ provider: zod.string().min(1), model: zod.string().min(1), @@ -55,13 +48,13 @@ export const subagentModelSelectionProjectionDefinition = { /** * Read the exact route list authorized for a Session. - * @param projections - registry that owns the policy projection. + * @param subagentModelSelectionProjectionHandle - registered policy state reader. * @param session - session whose durable decision is read. * @returns a detached route list, or undefined to restrict selection to the parent route. */ export function subagentModelSelectionPolicy( - projections: Pick, + subagentModelSelectionProjectionHandle: Pick, 'stateOf'>, session: Session, ): AllowedModelRoute[] | undefined { - return projections.stateOf(session, 'subagentModelSelectionPolicy')?.map(route => ({ ...route })) + return subagentModelSelectionProjectionHandle.stateOf(session)?.map(route => ({ ...route })) } diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 00138a4715..82e11af52e 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -8,6 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { SessionId } from '@deepseek-ai/dsh-session' import * as mock from './scripted-provider.ts' +import type { Config as toolConfig } from '../src/index.ts' import * as tool from '../src/index.ts' import SubagentModelSelectionConfig from '../src/model-selection-settings.ts' @@ -32,7 +33,7 @@ const setupProviders = new WeakMap() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(AgentLoop, { agents: [] }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementationOnce(async (definition) => { + const handle = await register(definition) + entered.resolve(undefined) + await release.promise + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const announcing = Promise.withResolvers() + ctx.on('agent/created', () => { announcing.resolve(undefined) }, { prepend: true }) + const fiber = ctx.plugin(SubagentModelSelectionConfig, { enabled: true, allowedModels: ALLOWED_MODELS }) + await entered.promise + return { ctx, fiber, release, announcing: announcing.promise } +} + +describe('SubagentModelSelectionConfig activation', () => { + it('records the policy for an Agent announced while registration is pending', async () => { + const { ctx, fiber, release, announcing } = await setup() + const creation = ctx.agents.create({ sessionId: SessionId('during-policy-activation') }) + await announcing + release.resolve(undefined) + const { agent } = await creation + await fiber + + expect(agent.session.snapshotEvents().filter(event => event.type === 'subagent/model-selection-policy')) + .toMatchObject([{ data: { allowedModels: ALLOWED_MODELS } }]) + }) + + it.each(['dispose', 'reject'] as const)('rejects Agent publication when policy activation ends with %s', async (ending) => { + const { ctx, fiber, release, announcing } = await setup() + const id = SessionId('failed-policy-activation') + const creation = ctx.agents.create({ sessionId: id }) + const rejected = expect(creation).rejects.toThrow() + await announcing + if (ending === 'dispose') { + const disposal = fiber.dispose() + release.resolve(undefined) + await disposal + } else { + const failed = expect(Promise.resolve(fiber)).rejects.toThrow('registration failed') + release.reject(new Error('registration failed')) + await failed + } + await rejected + + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + }) +}) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 4f27125f32..36442f05b2 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -82,7 +82,8 @@ let policySessionCounter = 0 /** Observe settings through the policy captured by a fresh top-level Agent. */ async function capturePolicy(ctx: Context) { const { agent } = await ctx.agents.create({ sessionId: SessionId(`settings-policy-${++policySessionCounter}`) }) - return subagentModelSelectionPolicy(ctx.sessionProjections, agent.session) + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + return subagentModelSelectionPolicy(policy, agent.session) } describe('SubagentModelSelectionConfig', () => { @@ -118,7 +119,7 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(AgentLoop, { agents: [] }) selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig)) - ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) // File-written invalid settings reject Agent creation even without delegation tools. await selectionConfigs.get(ctx)!.update({ allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS] }) @@ -136,13 +137,14 @@ describe('SubagentModelSelectionConfig', () => { const invalid = Session.create(SessionId('empty-policy')) invalid.append('subagent/model-selection-policy', { allowedModels: [] }) - expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, invalid)).toThrow('requires at least one route') + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(() => subagentModelSelectionPolicy(policy, invalid)).toThrow('requires at least one route') const malformed = Session.create(SessionId('malformed-policy')) malformed.append('subagent/model-selection-policy', { allowedModels: [{ provider: 1, model: 'fast-model' }], } as never) - expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, malformed)) + expect(() => subagentModelSelectionPolicy(policy, malformed)) .toThrow('requires non-empty provider and model ids') await ctx.fiber.dispose() }) @@ -151,14 +153,15 @@ describe('SubagentModelSelectionConfig', () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(true) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, disabled.session)).toBeUndefined() + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, disabled.session)).toBeUndefined() await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) const enabled = await createAgent(ctx, 'enabled') - expect(subagentModelSelectionPolicy(ctx.sessionProjections, enabled.session)).toEqual(ALLOWED_MODELS) + expect(subagentModelSelectionPolicy(policy, enabled.session)).toEqual(ALLOWED_MODELS) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(true) @@ -210,8 +213,9 @@ describe('SubagentModelSelectionConfig', () => { const preset = modelSelectionPresets.get(ctx)! bindScopeParent(scopeOf(joining.agent.ctx)!, scopeOf(preset.ctx)!) ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') - expect(subagentModelSelectionPolicy(ctx.sessionProjections, joining.agent.session)).toBeUndefined() - expect(subagentModelSelectionPolicy(ctx.sessionProjections, disabled.session)).toBeUndefined() + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, joining.agent.session)).toBeUndefined() + expect(subagentModelSelectionPolicy(policy, disabled.session)).toBeUndefined() await ctx.fiber.dispose() }) @@ -226,8 +230,9 @@ describe('SubagentModelSelectionConfig', () => { const outside = await ctx.agents.create({ sessionId: SessionId('existing-outside') }) await selectionConfigs.get(ctx)!.update({ enabled: false }) await preset.ctx.plugin(tool, { provider: 'spawn', modelSelection: true, modelDiscovery: true }) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, inside.agent.session)).toEqual(ALLOWED_MODELS) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, outside.agent.session)).toEqual(ALLOWED_MODELS) + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, inside.agent.session)).toEqual(ALLOWED_MODELS) + expect(subagentModelSelectionPolicy(policy, outside.agent.session)).toEqual(ALLOWED_MODELS) await ctx.fiber.dispose() }) @@ -377,7 +382,8 @@ describe('SubagentModelSelectionConfig', () => { meta: { parentSession: parent.id, origin: 'subagent' }, }) expect(selectable(ctx, child)).toBe(true) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, child.session)).toEqual(ALLOWED_MODELS) + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, child.session)).toEqual(ALLOWED_MODELS) const orphan = await createAgent(ctx, 'orphan', { meta: { parentSession: SessionId('missing-parent'), origin: 'subagent' }, @@ -396,11 +402,11 @@ describe('SubagentModelSelectionConfig', () => { }) const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] }) expect(selectable(ctx, resumedEmpty)).toBe(true) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() + expect(subagentModelSelectionPolicy(policy, resumedEmpty.session)).toBeUndefined() const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(true) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() + expect(subagentModelSelectionPolicy(policy, resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 62e27476a1..80fd1466fd 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -18,6 +18,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts' import * as mock from './scripted-provider.ts' +import type { Config as toolConfig } from '../src/index.ts' import * as tool from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -617,7 +618,7 @@ describe('dsh-tool-subagent', () => { .rejects.toThrow() }) - it('validates maxDepth when apply() is invoked directly without Schemastery', async () => { + it('validates maxDepth during initialization without Schemastery', async () => { const ctx = await projectedContext() await expect(tool.apply(ctx, { provider: 'unused', @@ -816,7 +817,7 @@ describe('dsh-tool-subagent local activation', () => { describe('depth budget configuration', () => { /** Mount the tool over a request-capturing provider with full capabilities. */ - async function captureSetup(config: Omit = {}) { + async function captureSetup(config: Omit = {}) { const requests: SubagentStartRequest[] = [] const ctx = await projectedContext() await mountWorkingDirectoryFixture(ctx) diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index a11f47e686..1aba27c0a1 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -36,7 +36,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, "dependencies": { @@ -52,6 +51,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 131e75b553..5359948eb6 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -13,7 +13,6 @@ import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' -import type {} from '@deepseek-ai/dsh-session-projection' import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' @@ -24,13 +23,12 @@ export type { Config as TerminalLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'terminal-bash' -/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */ -export const inject = ['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory'] +/** Required services: terminal registry, shared confinement policy, process substrate, and working directory. */ +export const inject = ['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory'] interface SandboxModeFenceState { pty: Context['terminals'] sandboxPolicy: Context['sandboxPolicy'] - sessionProjections: Context['sessionProjections'] } const sandboxModeFences = new WeakMap() @@ -40,20 +38,18 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (existing !== undefined) { existing.pty = ctx.terminals existing.sandboxPolicy = ctx.sandboxPolicy - existing.sessionProjections = ctx.sessionProjections return } const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy, - sessionProjections: ctx.sessionProjections, } sandboxModeFences.set(owner, state) owner.ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const folded = state.sessionProjections.stateOf(session, 'sandboxMode') ?? null + const folded = state.sandboxPolicy.overrideOf(session) ?? null const currentMode = folded ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index a68c798321..f3d991dbbb 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -624,16 +624,16 @@ describe('terminal-bash plugin shape', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(ptyLocal) as Record expect(unwrapped.name).toBe('terminal-bash') - expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory']) + expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory']) expect(unwrapped.Config).toBeDefined() }) it('validates config and registers the configured backend', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, config()) @@ -646,10 +646,10 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(EmptySandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) await ctx.plugin(ptyLocal, config()) @@ -665,10 +665,10 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) @@ -716,10 +716,10 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index e5a53123d0..c12f6181ed 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -61,10 +61,10 @@ async function harness( const ctx = new Context() provideWorkingDirectoryFixture(ctx) contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(PassthroughSandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, { diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 11da341945..bfd3d58ce3 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { @@ -120,6 +121,7 @@ function backend(type = 'stub') { async function harness() { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(TerminalSessionService) ptyServiceDisposers.set(ctx, async () => { await fiber.dispose() }) diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index 932912b4e4..7cdc154c72 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -110,7 +110,7 @@ describe('dsh-agent-loop-testkit', () => { time: 1, data: { target: 'next-turn', start: 99, inserted: [] }, }]) - expect(() => ctx.sessionProjections.stateOf(invalid, 'inbox')) + expect(() => ctx.sessionProjections.checkpoint(invalid).inbox?.val) .toThrow(/invalid persisted inbox splice/) expect(harness.claim(agent, 'next-turn', 3)).toEqual([step, turn]) expect(claimed).toEqual([ diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 8835e74614..0f59f7be17 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -113,14 +113,15 @@ const todosProjectionSchema: ZodType = zod.union([ * `ctx.sessionProjections`. * @param ctx - registrant context carrying the tool and session-projection registries. * @param config - deployment's explicit todo policy. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { const allowParallel = config.allowParallelInProgress // Standing-plan fold: latest whole todo/write list, cleared by the next // turn/start (turn/end keeps the finished checklist visible); null before the // first write or after a later turn begins; every other event returns the // same state reference. - ctx.sessionProjections.register<'todos', TodoItem[] | null>({ + await ctx.sessionProjections.register<'todos', TodoItem[] | null>({ key: 'todos', stateSchema: todosProjectionSchema, init: () => null, diff --git a/packages/todo/tool-todo/src/types.ts b/packages/todo/tool-todo/src/types.ts index 15f5dab88c..ab25ec9cf3 100644 --- a/packages/todo/tool-todo/src/types.ts +++ b/packages/todo/tool-todo/src/types.ts @@ -35,9 +35,6 @@ declare module '@deepseek-ai/dsh-session/types' { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - todos: TodoItem[] | null - } interface SessionProjectionMap { /** * The agent's current whole todo list (the latest `todo/write` snapshot), diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 20975df8c0..1c86347250 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -33,8 +33,8 @@ async function harness(withTodoTool: boolean): Promise { await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index ba5a2109bc..fcd63ec242 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -3,20 +3,20 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/README.md /: - en: 036b48d7ea9bdc0e - zh: e8e35eb01d8eb2d4 + en: 225f3432e1eb335a + zh: 5d5acd66295641da /util-shared-utilities: en: 7283f2afb343d150 zh: 32c56bd67c0314c6 /util-shared-utilities/summary: - en: 63e37eb06d07deb5 - zh: 26d94a8a8f9079ce + en: f21c74532a9c929e + zh: f34c908f47f4a58a /util-shared-utilities/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /util-shared-utilities/packages: - en: ad806472c407b81a - zh: 452ef35a52fedd9c + en: 6f788656548ee4e0 + zh: 227695dafaa9476d /util-shared-utilities/related-documentation: en: 432ae81110914571 zh: c5bf4efc007311f3 diff --git a/packages/util/README.md b/packages/util/README.md index 300ace8e7d..c305e1fb69 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for shared utilities: atomic file writes, branded ids, deques, JSON values, harness home paths, launch environment, native commands, output retention, time zones, and timeouts." +description: "Package map for shared utilities: atomic file writes, branded ids, deques, JSON values, harness home paths, launch environment, native commands, output retention, time and zone vocabulary, and timeouts." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone canonicalization, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics. +The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone, instant, and span vocabulary, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics. ## Table of Contents @@ -28,6 +28,7 @@ Each package provides one primitive; open a package page for how to use it. |---|---| | [`brand/`](brand/README.md) | Nominal string types and their stateless constructor | | [`package-manifest/`](package-manifest/README.md) | Shared TypeScript declarations for package manifests | +| [`plugin-detail/`](plugin-detail/README.md) | Selects plugin detail documents and reads them from local packages and npm file CDNs | | [`plugin-market-index/`](plugin-market-index/README.md) | Validates plugin market index format 1 and builds pinned install arguments | | [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive | | [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage | @@ -42,7 +43,7 @@ Each package provides one primitive; open a package page for how to use it. | [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers | | [`code-language/`](code-language/README.md) | Maps a filename suffix to the syntax-highlighting language that the Code preview, diff review, and the read card render | | [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata | -| [`time/`](time/README.md) | Validates and canonicalizes a caller-reported IANA time zone | +| [`time/`](time/README.md) | Parses strict instants, local calendars, and caller-reported IANA zones, and holds a millisecond span | | [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification | ----- diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index b98915c257..5dc7a201a6 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -1,5 +1,5 @@ --- -description: "共享工具家族的包映射:原子文件写入、品牌化 id、双端队列、JSON 值、harness 主目录路径、启动环境、原生命令、输出保留、时区与超时。" +description: "共享工具家族的包映射:原子文件写入、品牌化 id、双端队列、JSON 值、harness 主目录路径、启动环境、原生命令、输出保留、时间与时区词汇、超时。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、出站代理策略、原生命令、输出保留、时区规范化和超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 +`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、出站代理策略、原生命令、输出保留、时区、时刻与时间段词汇,以及超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 ## 目录 @@ -28,6 +28,7 @@ kind: "package-group" |---|---| | [`brand/`](brand/README.zh.md) | 提供名义字符串类型及其无状态构造函数 | | [`package-manifest/`](package-manifest/README.zh.md) | 包 manifest(元数据清单)的共享 TypeScript 声明 | +| [`plugin-detail/`](plugin-detail/README.zh.md) | 选择插件详细介绍文档,并从本地包和 npm 文件 CDN 读取它们 | | [`plugin-market-index/`](plugin-market-index/README.zh.md) | 校验插件市场索引格式 1,并生成锁定版本的安装参数 | | [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID | | [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 | @@ -42,7 +43,7 @@ kind: "package-group" | [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 | | [`code-language/`](code-language/README.zh.md) | 把文件名后缀映射为 Code 预览、diff 审阅与 read 卡片所渲染的语法高亮语言 | | [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 | -| [`time/`](time/README.zh.md) | 校验并规范化调用方所报的 IANA 时区 | +| [`time/`](time/README.zh.md) | 解析严格的时刻、本地日历与调用方所报的 IANA 时区,并持有毫秒精度的时间段 | | [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时和取消分类 | ----- diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index 2ce3905fcf..bb73ae8f0f 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 1db86285abf6c499 /deepseek-ai-dsh-package-manifest/use-this-package: - en: f275964a8845888a - zh: b284e09aacea079b + en: 0f5e861b0c0c5ee2 + zh: 766eaa36b794a22b /deepseek-ai-dsh-package-manifest/understand-the-implementation: en: 41ec449dee927f04 zh: ec1b583bd9025d84 diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index d50312947e..a1d5e7f423 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -43,7 +43,7 @@ const manifest: DshPackageManifest = { } ``` -`DshPackageManifest` describes the package.json fields used by DSH, with required `name` and `version`; it is not an exhaustive npm schema. Local profile readers use `Partial` because profiles need no published version. `DshManifest` describes only public author fields under `dsh`. `DshBundleManifest.patch` is one patch file path or an ordered list of them, each relative to the package root; the launcher applies a list in order as one bundle layer. TypeScript checks the example and erases `import type`; these interfaces do not parse JSON or write a file. +`DshPackageManifest` describes the package.json fields used by DSH, with required `name` and `version`; it includes dependency, peer, and optional dependency declarations but is not an exhaustive npm schema. Local profile readers use `Partial` because profiles need no published version. `DshManifest` describes only public author fields under `dsh`. `DshBundleManifest.patch` is one patch file path or an ordered list of them, each relative to the package root; the launcher applies a list in order as one bundle layer. TypeScript checks the example and erases `import type`; these interfaces do not parse JSON or write a file. The following metadata fields are optional. Omitting them leaves the format version or compatible host versions undeclared; readers do not infer defaults. diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index 9a535a094a..d676ff9bf7 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -43,7 +43,7 @@ const manifest: DshPackageManifest = { } ``` -`DshPackageManifest` 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填;它不是完整的 npm schema(模式)。本地 profile 读取方使用 `Partial`,因为 profile 无需发布版本。`DshManifest` 仅描述 `dsh` 下的公共作者字段。`DshBundleManifest.patch` 是一个 patch 文件路径,或一个有序的路径列表,均相对于包根目录;launcher 按列表顺序把它们作为同一个组合包层应用。TypeScript 检查示例并删除 `import type`;这些接口不解析 JSON,也不写入文件。 +`DshPackageManifest` 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填;它包含普通依赖、peer 依赖及可选依赖声明,未覆盖 npm 的所有字段。本地 profile 读取方使用 `Partial`,因为 profile 无需发布版本。`DshManifest` 仅描述 `dsh` 下的公共作者字段。`DshBundleManifest.patch` 是一个 patch 文件路径,或一个有序的路径列表,均相对于包根目录;launcher 按列表顺序把它们作为同一个组合包层应用。TypeScript 检查示例并删除 `import type`;这些接口不解析 JSON,也不写入文件。 以下元数据字段均可选。省略时,格式版本或兼容的宿主版本保持未声明状态;读取方不推断默认值。 diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index e5289ad1e2..27de17acfe 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -18,6 +18,8 @@ export interface DshPackageManifest { private?: boolean /** Packages installed alongside this package. */ dependencies?: Record + /** Packages that may be absent when installation or platform checks skip them. */ + optionalDependencies?: Record /** Compatible versions of packages supplied by the consuming project. */ peerDependencies?: Record /** Runtime requirements; DSH compatibility is declarative until a reader enforces it. */ diff --git a/packages/util/plugin-detail/README.i18n.yaml b/packages/util/plugin-detail/README.i18n.yaml new file mode 100644 index 0000000000..cb12152f41 --- /dev/null +++ b/packages/util/plugin-detail/README.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for README.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 packages/util/plugin-detail/README.md +/: + en: 505fd12717a89948 + zh: 2ad120714356f61b +/dsh-plugin-detail: + en: 9ef51ea35aa9ea60 + zh: 341593614dac3945 +/dsh-plugin-detail/summary: + en: 9a5bdafd69ed0208 + zh: d436f289724bab19 +/dsh-plugin-detail/table-of-contents: + en: cd2a60a905c978d6 + zh: cc71d387d0cbd442 +/dsh-plugin-detail/use-this-package: + en: ccdf3fdf80317f76 + zh: 023b893b30d1dd5b +/dsh-plugin-detail/use-this-package/document-order: + en: fe28217d182b9174 + zh: 0a0b4a61ee4009eb +/dsh-plugin-detail/use-this-package/images: + en: abeb7bb1f7695e5e + zh: 59b87c9abea17dfb +/dsh-plugin-detail/use-this-package/file-sources: + en: 9542401a7d104851 + zh: 99f80d2960b77bc8 +/dsh-plugin-detail/use-this-package/image-responses: + en: d6e7da207ee7f2fb + zh: c7b6acaa4d2839a2 +/dsh-plugin-detail/api: + en: ed9c0c624558d944 + zh: 9373180d9edb1012 +/dsh-plugin-detail/model-experience: + en: 60eeeeff73bf48b4 + zh: cf3e6e1885719d6a +/dsh-plugin-detail/model-experience/kv-cache-effect: + en: 4f0eadf94828e66e + zh: 4f8a6ec170ebbfb6 +/dsh-plugin-detail/known-limitations-and-deferred-work: + en: 5d8abfbb06761f7f + zh: 8515079f96e513bf +/dsh-plugin-detail/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/util/plugin-detail/README.md b/packages/util/plugin-detail/README.md new file mode 100644 index 0000000000..b80404055c --- /dev/null +++ b/packages/util/plugin-detail/README.md @@ -0,0 +1,101 @@ +--- +description: "Plugin detail document selection without state: the meta.detail or README order, language fallback, image paths, and local and npm CDN file sources." +kind: "package-library" +--- + +# dsh-plugin-detail + +English | [中文](README.zh.md) + +## Summary + +Library without state for plugin detail documents, the long Markdown description a plugin's page shows. It owns the order in which a package's document is chosen, the language fallback, image destinations beside a document, and two read-only file sources: a local package directory and a published npm version on public file CDNs. [`plugin-manager`](../../boot/plugin-manager/README.md#bundle-detail-documents) reads profile bundles with it, and [`plugin-market`](../../boot/plugin-market/README.md#plugin-detail-documents) reads market entries with it and adds repository sources. + +## Table of Contents + +- [Use this package](#use-this-package) +- [API](#api) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +It is a library, not a service or plugin: no `ctx`, no registrations, no state. Reads go through the file source the caller passes. + + +### Document order + +A plugin's detail document is the first that exists, in this order: + +1. **Locale `meta.detail`.** For each language of the chain, the package's `locale/.json` is read, or, when that file does not exist, the same name with an upper-case two-letter region such as `locale/zh-CN.json`. When the file read declares `meta.detail`, that Markdown file is the document; otherwise the next language is tried. +2. **Package README.** `README.md`, `readme.md`, `Readme.md`, `README.markdown`, or `README`. A source that lists directories takes any letter case of `README`, `README.md`, and `README.markdown`. +3. **Repository README, market entries only.** The README of the repository directory that the market entry's `repository` names, otherwise the one that the package's `package.json` `repository` names, at the default branch. [`plugin-market`](../../boot/plugin-market/README.md#plugin-detail-documents) reads it; bundle pages in **Plugins** never read a repository. + +`readPackageDocument(files, dir, languages, maxBytes, signal)` implements steps 1 and 2 and returns `undefined` when neither applies. `meta.detail` must be a `/`-separated path relative to the package root that ends in `.md` or `.markdown` and has no empty, `.`, or `..` segment, backslash, or URL scheme. A malformed locale file, a malformed `meta.detail`, and a named file that does not exist fail with `DetailFailure` `invalid`; the README is not read in their place. + +`languageChain(language)` builds the chain: the language in lower case, each shorter prefix, then English, so `zh-Hans-CN` reads `zh-hans-cn`, `zh-hans`, `zh`, and `en`. + +The returned Markdown has no byte order mark and no leading YAML front matter block. + +### Images + +`detailImage(document, destination)` resolves an image a document writes: relative to the document, or to the root of its files when it starts with `/`; a query or fragment is ignored. It returns the image's path and media type, or `undefined` for a destination that leaves the root, is malformed, or names a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG. + +### File sources + +- `localFiles(root, revision)` reads a package directory. A path whose real location leaves the directory reads as absent. `revision`, such as the installed version, becomes part of the source's `key`. +- `npmFiles(name, version, sources, options)` reads a published version from jsDelivr, unpkg, and npmmirror in the given order. A 404 means the package has no such file; a network failure or another status asks the next CDN. `npmFileSourceOrder(configured, preferred)` puts the preferred CDNs that are configured first, in the preferred order, then the other configured ones. +- `fetchBody(url, maxBytes, signal, options)` reads one HTTP body up to `maxBytes`, following redirects within the URL's origin. + +Every read is bounded by the caller's `maxBytes` and fails with `DetailFailure` `too-large` beyond it, or `network` when a source cannot be reached. + +### Image responses + +`detailImageResponse(outcome, cacheControl)` answers an image route: the image with `cacheControl`, 404 when there is none, or the failure's status (`too-large` 413, `network` 502, `denied` 403, `git-unavailable` 503, `invalid` 400) with the reason as its body and `Cache-Control: no-store`. Every response carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`, because an SVG opened directly would otherwise run scripts on the authenticated API origin. + +----- + + +## API + +```ts +import { + DetailFailure, detailImage, detailImageResponse, languageChain, localFiles, NPM_FILE_SOURCES, npmFiles, npmFileSourceOrder, + readPackageDocument, +} from '@deepseek-ai/dsh-plugin-detail' +``` + +`DetailFailureReason` and `NpmFileSource`, which Remote methods of other packages use, are also exported type-only from `@deepseek-ai/dsh-plugin-detail/types`. + +`DetailFiles` is the source interface: `read(path, maxBytes, signal)` returns a file's bytes or `undefined`, and `readme(dir, maxBytes, signal)` returns a directory's README. Repository sources implement it in `plugin-market`. + +----- + + +## Model Experience + +None, as this library selects documents for plugin pages and adds nothing to model requests. + +#### KV Cache effect + +None. + +## Known Limitations and Deferred Work + + + +- **Public npm only** — the file CDNs serve packages of the public npm registry; packages of other registries have no npm file source. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    diff --git a/packages/util/plugin-detail/README.zh.md b/packages/util/plugin-detail/README.zh.md new file mode 100644 index 0000000000..2faa23c4e8 --- /dev/null +++ b/packages/util/plugin-detail/README.zh.md @@ -0,0 +1,101 @@ +--- +description: "无状态的插件详细介绍文档选择:meta.detail 或 README 的顺序、语言回退、图片路径,以及本地和 npm CDN 文件来源。" +kind: "package-library" +--- + +# dsh-plugin-detail + +[English](README.md) | 中文 + +## 概述 + +用于插件详细介绍文档的无状态库。详细介绍是插件页面显示的较长 Markdown 描述。本包负责选择包文档的顺序、语言回退、文档旁图片的目标路径,以及两种只读文件来源:本地包目录,以及公共文件 CDN 上已发布的 npm 版本。[`plugin-manager`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 用它读取 profile 中的组合包,[`plugin-market`](../../boot/plugin-market/README.zh.md#plugin-detail-documents) 用它读取市场条目,并额外提供仓库来源。 + +## 目录 + +- [使用本包](#use-this-package) +- [API](#api) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +本包是库,不是服务或插件:没有 `ctx`、没有注册,也没有状态。所有读取都经由调用方传入的文件来源进行。 + + +### 文档顺序 + +插件的详细介绍文档是按以下顺序第一个存在的文档: + +1. **locale 中的 `meta.detail`。** 对语言链中的每种语言,读取包的 `locale/.json`;该文件不存在时,读取地区部分为大写两字母的同名文件,例如 `locale/zh-CN.json`。读取到的文件声明 `meta.detail` 时,该 Markdown 文件即为文档;否则尝试下一种语言。 +2. **包 README。** `README.md`、`readme.md`、`Readme.md`、`README.markdown` 或 `README`。能列出目录内容的来源接受任意大小写的 `README`、`README.md` 和 `README.markdown`。 +3. **仓库 README,仅限市场条目。** 市场条目的 `repository` 所指仓库目录中的 README;条目没有 `repository` 时,使用包 `package.json` 的 `repository` 所指仓库目录中的 README;均读取默认分支上的文件。[`plugin-market`](../../boot/plugin-market/README.zh.md#plugin-detail-documents) 读取此步;**插件**中的组合包页面从不读取仓库。 + +`readPackageDocument(files, dir, languages, maxBytes, signal)` 实现第 1 步和第 2 步,两者都不适用时返回 `undefined`。`meta.detail` 必须是相对于包根目录、以 `/` 分隔、以 `.md` 或 `.markdown` 结尾的路径,且不含空段、`.` 或 `..` 段、反斜杠或 URL scheme。locale 文件格式错误、`meta.detail` 格式错误,或指定的文件不存在时,以 `DetailFailure` `invalid` 失败;不会改为读取 README。 + +`languageChain(language)` 构建语言链:先是小写形式的该语言,然后是每个更短的前缀,最后是英文,因此 `zh-Hans-CN` 依次读取 `zh-hans-cn`、`zh-hans`、`zh` 和 `en`。 + +返回的 Markdown 不含字节顺序标记(BOM),也不含开头的 YAML front matter 块。 + +### 图片 + +`detailImage(document, destination)` 解析文档中引用的图片:相对于该文档;以 `/` 开头时相对于其文件的根目录;查询和片段被忽略。它返回图片的路径和媒体类型;目标路径超出根目录、格式错误,或指定 PNG、JPEG、GIF、WebP、AVIF 和 SVG 之外的类型时,返回 `undefined`。 + +### 文件来源 + +- `localFiles(root, revision)` 读取一个包目录。实际位置超出该目录的路径按不存在处理。`revision`(例如已安装的版本)成为该来源 `key` 的一部分。 +- `npmFiles(name, version, sources, options)` 按给定顺序从 jsDelivr、unpkg 和 npmmirror 读取一个已发布版本。404 表示该包没有此文件;网络失败或其他状态码时改为请求下一个 CDN。`npmFileSourceOrder(configured, preferred)` 把已配置的优先 CDN 按优先顺序排在最前,其后是其他已配置的 CDN。 +- `fetchBody(url, maxBytes, signal, options)` 读取一个 HTTP 响应体,最多 `maxBytes`,并跟随该 URL 的 origin 内的重定向。 + +每次读取都受调用方 `maxBytes` 的限制,超过时以 `DetailFailure` `too-large` 失败;来源无法访问时以 `network` 失败。 + +### 图片响应 + +`detailImageResponse(outcome, cacheControl)` 生成图片路由的响应:有图片时返回该图片并带 `cacheControl`;没有图片时返回 404;失败时返回该失败的状态码(`too-large` 为 413、`network` 为 502、`denied` 为 403、`git-unavailable` 为 503、`invalid` 为 400),以失败原因作为响应体,并带 `Cache-Control: no-store`。每个响应都带有 `X-Content-Type-Options: nosniff` 和 `Content-Security-Policy: sandbox`,因为否则直接打开的 SVG 会在已通过认证的 API origin 上运行脚本。 + +----- + + +## API + +```ts +import { + DetailFailure, detailImage, detailImageResponse, languageChain, localFiles, NPM_FILE_SOURCES, npmFiles, npmFileSourceOrder, + readPackageDocument, +} from '@deepseek-ai/dsh-plugin-detail' +``` + +`DetailFailureReason` 和 `NpmFileSource` 供其他包的 Remote 方法使用,也从 `@deepseek-ai/dsh-plugin-detail/types` 以仅类型方式导出。 + +`DetailFiles` 是来源接口:`read(path, maxBytes, signal)` 返回文件的字节或 `undefined`,`readme(dir, maxBytes, signal)` 返回目录的 README。仓库来源在 `plugin-market` 中实现该接口。 + +----- + + +## 模型体验 + +无,因为本库为插件页面选择文档,不向模型请求添加任何内容。 + +#### KV Cache 影响 + +无。 + +## 已知限制与延期工作 + + + +- **只支持公共 npm**——文件 CDN 提供公共 npm 注册表中的包;其他注册表中的包没有 npm 文件来源。 + + +### 开发备注 + +
    +维护者的工作上下文——点击展开 + +无。 + +
    diff --git a/packages/util/plugin-detail/package.json b/packages/util/plugin-detail/package.json new file mode 100644 index 0000000000..db24c51bce --- /dev/null +++ b/packages/util/plugin-detail/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-plugin-detail", + "description": "Plugin detail documents: meta.detail or README selection, image paths, and local and npm CDN file sources", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/plugin-detail" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/util/plugin-detail/src/document.ts b/packages/util/plugin-detail/src/document.ts new file mode 100644 index 0000000000..2752afd9f2 --- /dev/null +++ b/packages/util/plugin-detail/src/document.ts @@ -0,0 +1,137 @@ +/** + * Plugin detail document selection: the Markdown file a package's locale `meta.detail` names in a display language, + * otherwise the package's README; and the images beside a selected document. + */ +import { posix } from 'node:path' +import { DetailFailure, joinPath, type DetailFiles } from './files.ts' + +const LANGUAGE_ID = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/u + +/** + * The languages a document is chosen in: the language, each shorter prefix, then English. + * @param language - a BCP 47 language id such as `zh-CN`. + * @returns lower-case language ids, most specific first. + * @throws when `language` is not a language id. + */ +export function languageChain(language: string): string[] { + const id = language.toLowerCase() + if (!LANGUAGE_ID.test(id)) throw new Error(`Not a language id: ${language}`) + const parts = id.split('-') + const chain = parts.map((_part, index) => parts.slice(0, parts.length - index).join('-')) + return [...new Set([...chain, 'en'])] +} + +/** + * A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it. + * @throws {DetailFailure} `invalid` for another value. + */ +function detailPath(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined + const invalid = (rule: string): DetailFailure => new DetailFailure('invalid', `${label} ${rule}`) + if (typeof value !== 'string' || value.trim() === '') throw invalid('must be a non-empty string') + if (value.includes('\\') || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || value.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) { + throw invalid('must be a relative path inside the package') + } + if (!/\.(?:md|markdown)$/iu.test(value)) throw invalid('must name a Markdown file') + return value +} + +/** `meta.detail` of the first `locale/.json` along the chain that declares one. */ +async function localeDetail( + files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal, +): Promise { + for (const language of languages) { + // Locale files are named by language id: lower case, and also with an upper-case two-letter region. + const names = [...new Set([language, language.replace(/^([a-z]{2,3})-([a-z]{2})$/u, (_match, base: string, region: string) => `${base}-${region.toUpperCase()}`)])] + for (const name of names) { + const path = joinPath(dir, `locale/${name}.json`) + const bytes = await files.read(path, maxBytes, signal) + if (bytes === undefined) continue + let meta: unknown + try { + meta = (JSON.parse(new TextDecoder().decode(bytes)) as { meta?: unknown } | null)?.meta + } catch (error) { + throw new DetailFailure('invalid', `${path}: ${(error as SyntaxError).message}`, { cause: error }) + } + const detail = detailPath(typeof meta === 'object' && meta !== null ? (meta as { detail?: unknown }).detail : undefined, `${path}: meta.detail`) + if (detail !== undefined) return detail + break + } + } + return undefined +} + +/** + * Decode a Markdown document: UTF-8 without a byte order mark, without a leading YAML front matter block, which is file + * metadata rather than description text. + * @param bytes - the file's bytes. + * @returns the Markdown text. + */ +export function markdownOf(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes).replace(/^\uFEFF/u, '').replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/u, '') +} + +/** A package's detail document. */ +export interface PackageDocument { + /** `locale` for the file `meta.detail` names, `readme` for the package's README. */ + readonly origin: 'locale' | 'readme' + /** `/`-separated path of the document inside the files. */ + readonly path: string + /** The document, decoded by {@link markdownOf}. */ + readonly markdown: string +} + +/** + * Read a package's detail document: the Markdown file that `meta.detail` in its `locale/.json` names for the + * first language of the chain that declares one, otherwise its README. + * @param files - the package's files. + * @param dir - package directory inside `files`, `''` for its root. + * @param languages - the chain from {@link languageChain}. + * @param maxBytes - largest accepted locale file and document. + * @param signal - cancellation of the reads. + * @returns the document, or undefined when the package has neither. + * @throws {DetailFailure} `invalid` for a malformed locale file or `meta.detail` or a named file that does not exist, and + * the failures of `files`. + */ +export async function readPackageDocument( + files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal, +): Promise { + const detail = await localeDetail(files, dir, languages, maxBytes, signal) + if (detail !== undefined) { + const path = joinPath(dir, detail) + const bytes = await files.read(path, maxBytes, signal) + if (bytes === undefined) throw new DetailFailure('invalid', `${path}, named by meta.detail, does not exist`) + return { origin: 'locale', path, markdown: markdownOf(bytes) } + } + const readme = await files.readme(dir, maxBytes, signal) + return readme === undefined ? undefined : { origin: 'readme', path: readme.path, markdown: markdownOf(readme.bytes) } +} + +/** Image file types served beside a document, by lower-case extension. */ +const IMAGE_TYPES = new Map([ + ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'], + ['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.svg', 'image/svg+xml'], +]) + +/** + * Resolve an image destination a document writes. + * @param document - `/`-separated path of the document inside its files. + * @param authored - the destination as written: relative to the document, or to the root of its files when it starts + * with `/`; a query or fragment is ignored. + * @returns the image's path inside the files and its media type, or undefined for a malformed destination, a path that + * leaves the root, or a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG. + */ +export function detailImage(document: string, authored: string): { path: string; mediaType: string } | undefined { + let decoded: string + try { + decoded = decodeURIComponent(authored.replace(/[?#].*$/su, '')) + } catch (_malformed) { + // A destination with a malformed percent escape names no file. + return undefined + } + if (decoded.includes('\\') || decoded.includes('\0')) return undefined + const path = posix.normalize(decoded.startsWith('/') ? decoded.slice(1) : posix.join(posix.dirname(document), decoded)) + if (path === '.' || path.startsWith('../') || path === '..' || posix.isAbsolute(path)) return undefined + const mediaType = IMAGE_TYPES.get(posix.extname(path).toLowerCase()) + return mediaType === undefined ? undefined : { path, mediaType } +} diff --git a/packages/util/plugin-detail/src/files.ts b/packages/util/plugin-detail/src/files.ts new file mode 100644 index 0000000000..0ace2e2d3f --- /dev/null +++ b/packages/util/plugin-detail/src/files.ts @@ -0,0 +1,283 @@ +/** + * Read-only file sources for plugin detail documents: a local package directory and published npm package versions on + * public file CDNs, with bounded HTTP reads. Sources keep no state between calls. + */ +import { lstat, readdir, readFile, realpath } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import type { DetailFailureReason, NpmFileSource } from './types.ts' + + +/** A read that must not fall back: the source exists but refused, failed, or exceeded a limit. */ +export class DetailFailure extends Error { + /** + * @param reason - the failure kind a reader reports. + * @param message - the diagnostic, naming the source. + * @param options - the underlying error. + */ + constructor(readonly reason: DetailFailureReason, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'DetailFailure' + } +} + +/** One file found by name lookup. */ +export interface FoundFile { + /** `/`-separated path relative to the source root. */ + readonly path: string + readonly bytes: Uint8Array +} + +/** Files below one root, read by `/`-separated relative paths that never start with `/` or contain `.` or `..` segments. */ +export interface DetailFiles { + /** Identity of the files' content: two sources with the same key read the same bytes. */ + readonly key: string + /** + * Read one regular file. + * @param path - path relative to the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the file's bytes, or undefined when it does not exist. + * @throws {DetailFailure} when the source fails or the file exceeds `maxBytes`. + */ + read(path: string, maxBytes: number, signal: AbortSignal): Promise + /** + * Read the README of a directory: `README.md`, `README.markdown`, or `README` in any letter case. + * @param dir - directory relative to the root; `''` for the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the file, or undefined when the directory has none. + * @throws {DetailFailure} when the source fails or the file exceeds `maxBytes`. + */ + readme(dir: string, maxBytes: number, signal: AbortSignal): Promise +} + +/** README names tried in order where a source cannot list a directory. */ +export const README_NAMES: readonly string[] = ['README.md', 'readme.md', 'Readme.md', 'README.markdown', 'README'] +const README_PATTERN = /^readme(?:\.md|\.markdown)?$/iu + +/** + * Join a directory and a file name into a root-relative path. + * @param dir - directory relative to the root, `''` for the root. + * @param name - path relative to `dir`. + * @returns the joined path. + */ +export function joinPath(dir: string, name: string): string { + return dir === '' ? name : `${dir}/${name}` +} + +/** + * Pick the README entry of a directory listing by the order of {@link README_NAMES}, then by name. + * @param names - file names of the directory. + * @returns the README name, or undefined. + */ +export function readmeName(names: readonly string[]): string | undefined { + const rank = (name: string): number => { + const index = README_NAMES.indexOf(name) + return index === -1 ? README_NAMES.length : index + } + return names.filter(name => README_PATTERN.test(name)).sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1))[0] +} + +/** Reads one file: its bytes, or undefined when it is absent. */ +export type ReadFile = (path: string, maxBytes: number, signal: AbortSignal) => Promise + +/** + * Read the first README of `names` that exists, for sources that cannot list a directory. + * @param read - reads one file. + * @param dir - directory relative to the root; `''` for the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the reads. + * @param names - the names to try, in order. + * @returns the file, or undefined when none exists. + * @throws {DetailFailure} what `read` throws. + */ +export async function readmeByName( + read: ReadFile, dir: string, maxBytes: number, signal: AbortSignal, names: readonly string[] = README_NAMES, +): Promise { + for (const name of names) { + const path = joinPath(dir, name) + const bytes = await read(path, maxBytes, signal) + if (bytes !== undefined) return { path, bytes } + } + return undefined +} + +function tooLarge(label: string, maxBytes: number): DetailFailure { + return new DetailFailure('too-large', `${label} exceeds ${String(maxBytes)} bytes`) +} + +/** + * Files of a local package directory. A path whose real location leaves the directory reads as absent. + * @param root - absolute package directory. + * @param revision - the installed version, so a reinstalled package reads as different files. + * @returns the files. + */ +export function localFiles(root: string, revision: string): DetailFiles { + const files: DetailFiles = { + key: `local:${root}@${revision}`, + async read(path, maxBytes) { + let file: string + try { + const base = await realpath(root) + file = await realpath(join(base, ...path.split('/'))) + const inside = relative(base, file) + if (inside === '' || inside === '..' || inside.startsWith(`..${sep}`)) return undefined + const info = await lstat(file) + if (!info.isFile()) return undefined + if (info.size > maxBytes) throw tooLarge(path, maxBytes) + } catch (error) { + if (error instanceof DetailFailure) throw error + // A missing file or directory, or one the process may not read, is a file the package does not provide. + return undefined + } + const bytes = await readFile(file) + /* v8 ignore next -- the file grew between its size check and its read; no test controls that timing */ + if (bytes.byteLength > maxBytes) throw tooLarge(path, maxBytes) + return bytes + }, + async readme(dir, maxBytes, signal) { + let names: string[] + try { + names = (await readdir(join(root, ...dir === '' ? [] : dir.split('/')), { withFileTypes: true })) + .filter(entry => entry.isFile() || entry.isSymbolicLink()).map(entry => entry.name) + } catch (_missing) { + // An absent or unreadable directory has no README. + return undefined + } + const name = readmeName(names) + if (name === undefined) return undefined + const path = joinPath(dir, name) + const bytes = await files.read(path, maxBytes, signal) + return bytes === undefined ? undefined : { path, bytes } + }, + } + return files +} + +/** Request settings of a remote file source. */ +export interface RemoteFileOptions { + readonly timeoutMs: number + /** Cancellation that outlives one read, such as the owning service's disposal. */ + readonly lifetime: AbortSignal +} + +/** One HTTP answer: the body of a 2xx response, or the status of any other. */ +export type FetchedBody = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false; readonly status: number } + +/** Redirects followed within one origin, such as a renamed repository's raw files; a redirect to another origin is not followed. */ +const MAX_REDIRECTS = 3 + +/** + * GET a URL, following redirects within its origin, and read the body up to `maxBytes`. + * @param url - the request URL. + * @param maxBytes - largest accepted body. + * @param signal - cancellation of the read. + * @param options - deadline and service lifetime. + * @returns the body, or the status of a non-2xx response, including a redirect to another origin. + * @throws {DetailFailure} `network` when no response arrives or the body fails, `too-large` over `maxBytes`. + */ +export async function fetchBody(url: string, maxBytes: number, signal: AbortSignal, options: RemoteFileOptions): Promise { + const combined = AbortSignal.any([signal, options.lifetime, AbortSignal.timeout(options.timeoutMs)]) + let response: Response | undefined + let target = url + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + try { + response = await fetch(target, { signal: combined, redirect: 'manual' }) + } catch (error) { + // A cancelled read says nothing about the source, so it keeps the caller's reason. + signal.throwIfAborted() + options.lifetime.throwIfAborted() + throw new DetailFailure('network', `Could not fetch ${target}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }) + } + const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null + if (location === null || !URL.canParse(location, target) || new URL(location, target).origin !== new URL(url).origin) break + await response.body?.cancel() + target = new URL(location, target).href + } + // The loop runs at least once. + const answer = response as Response + if (!answer.ok) { + await answer.body?.cancel() + return { ok: false, status: answer.status } + } + if (Number(answer.headers.get('content-length') ?? 0) > maxBytes) { + await answer.body?.cancel() + throw tooLarge(url, maxBytes) + } + const chunks: Uint8Array[] = [] + let size = 0 + try { + for await (const chunk of answer.body ?? []) { + size += chunk.byteLength + if (size > maxBytes) throw tooLarge(url, maxBytes) + chunks.push(chunk) + } + } catch (error) { + if (error instanceof DetailFailure) throw error + throw new DetailFailure('network', `Could not read ${url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }) + } + return { ok: true, bytes: Buffer.concat(chunks) } +} + +/** Every npm file CDN, in the default order. */ +export const NPM_FILE_SOURCES = ['jsdelivr', 'unpkg', 'npmmirror'] as const satisfies readonly NpmFileSource[] + +/** + * Order the configured npm file CDNs by a caller's preference: the preferred ones that are configured, in the preferred + * order, then the other configured ones in configured order. A preferred CDN that is not configured is not asked. + * @param configured - the configured CDNs in order. + * @param preferred - CDNs to ask first, such as those a Client measured as fastest; names other than CDNs are ignored. + * @returns the CDNs to ask, first to last. + */ +export function npmFileSourceOrder(configured: readonly NpmFileSource[], preferred: readonly string[] = []): NpmFileSource[] { + const first = configured.filter(source => preferred.includes(source)).sort((a, b) => preferred.indexOf(a) - preferred.indexOf(b)) + return [...first, ...configured.filter(source => !first.includes(source))] +} + +/** + * Percent-encode each segment of a `/`-separated path for a URL. + * @param path - the path. + * @returns the encoded path. + */ +export function encodePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +const NPM_FILE_URLS: Record string> = { + jsdelivr: (name, version, path) => `https://cdn.jsdelivr.net/npm/${name}@${version}/${encodePath(path)}`, + unpkg: (name, version, path) => `https://unpkg.com/${name}@${version}/${encodePath(path)}`, + npmmirror: (name, version, path) => `https://registry.npmmirror.com/${name}/${version}/files/${encodePath(path)}`, +} + +/** + * Files of one published npm package version, read from public file CDNs in order. A 404 from a CDN means the package + * does not contain the file; a network failure or another status asks the next CDN. + * @param name - package name. + * @param version - exact version. + * @param sources - the CDNs to ask, first to last; never empty. + * @param options - deadline and service lifetime. + * @returns the files. + */ +export function npmFiles(name: string, version: string, sources: readonly NpmFileSource[], options: RemoteFileOptions): DetailFiles { + const files: DetailFiles = { + key: `npm:${name}@${version}`, + async read(path, maxBytes, signal) { + const failures: string[] = [] + for (const source of sources) { + const url = NPM_FILE_URLS[source](name, version, path) + try { + const body = await fetchBody(url, maxBytes, signal, options) + if (body.ok) return body.bytes + if (body.status === 404) return undefined + failures.push(`${url}: HTTP ${String(body.status)}`) + } catch (error) { + if (!(error instanceof DetailFailure) || error.reason !== 'network') throw error + failures.push(error.message) + } + } + throw new DetailFailure('network', failures.join('; ')) + }, + readme: (dir, maxBytes, signal) => readmeByName((path, max, readSignal) => files.read(path, max, readSignal), dir, maxBytes, signal), + } + return files +} diff --git a/packages/util/plugin-detail/src/index.ts b/packages/util/plugin-detail/src/index.ts new file mode 100644 index 0000000000..e715ca8090 --- /dev/null +++ b/packages/util/plugin-detail/src/index.ts @@ -0,0 +1,13 @@ +/** + * Plugin detail documents without state: language chains, the `meta.detail` or README selection of a package, image + * destinations beside a document, and read-only file sources for local packages and published npm versions. + * @module @deepseek-ai/dsh-plugin-detail + */ + +export { + DetailFailure, encodePath, fetchBody, joinPath, localFiles, NPM_FILE_SOURCES, npmFileSourceOrder, npmFiles, README_NAMES, + readmeByName, readmeName, type DetailFiles, type FetchedBody, type FoundFile, type ReadFile, type RemoteFileOptions, +} from './files.ts' +export { detailImage, languageChain, markdownOf, readPackageDocument, type PackageDocument } from './document.ts' +export { detailImageResponse, type DetailImage } from './response.ts' +export type { DetailFailureReason, NpmFileSource } from './types.ts' diff --git a/packages/util/plugin-detail/src/response.ts b/packages/util/plugin-detail/src/response.ts new file mode 100644 index 0000000000..08cdf2af5d --- /dev/null +++ b/packages/util/plugin-detail/src/response.ts @@ -0,0 +1,37 @@ +/** HTTP responses of Host routes that serve images beside detail documents. */ +import type { DetailFailureReason } from './types.ts' + +/** An image read beside a detail document. */ +export interface DetailImage { + readonly bytes: Uint8Array + readonly mediaType: string +} + +/** HTTP status of each detail failure. */ +const FAILURE_STATUS = { + 'too-large': 413, 'network': 502, 'denied': 403, 'git-unavailable': 503, 'invalid': 400, +} as const satisfies Record + +/** + * The response of an image route: the image, 404 when there is none, or the failure's status with its reason as the + * body. Every response carries `X-Content-Type-Options: nosniff` and a sandbox content security policy, because an SVG + * opened directly would otherwise run scripts on the authenticated API origin; only an image gets `cacheControl`. + * @param outcome - the image, undefined when none exists, or why it cannot be read; `invalid` also answers a malformed request. + * @param cacheControl - the `Cache-Control` value of an image. + * @returns the response. + */ +export function detailImageResponse(outcome: DetailImage | DetailFailureReason | undefined, cacheControl: string): Response { + const [status, body, type] = typeof outcome === 'object' + // A copy into a fresh buffer is a body `Response` accepts whatever buffer the bytes view. + ? [200, new Uint8Array(outcome.bytes), outcome.mediaType] + : outcome === undefined ? [404, 'not found', undefined] : [FAILURE_STATUS[outcome], outcome, undefined] + return new Response(body, { + status, + headers: { + 'Content-Type': type ?? 'text/plain; charset=utf-8', + 'Cache-Control': status === 200 ? cacheControl : 'no-store', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'", + }, + }) +} diff --git a/packages/util/plugin-detail/src/types.ts b/packages/util/plugin-detail/src/types.ts new file mode 100644 index 0000000000..4631b64154 --- /dev/null +++ b/packages/util/plugin-detail/src/types.ts @@ -0,0 +1,12 @@ +/** Plugin detail records that Remote methods and their clients share. */ + +/** + * Why a detail document or one of its images cannot be read: `too-large` beyond a size limit, `network` when a source + * cannot be reached, `denied` when Git refuses the repository with the user's credentials, `git-unavailable` when a + * repository must be cloned and Git cannot run, and `invalid` for a malformed locale file or `meta.detail`, or a named + * file that does not exist. + */ +export type DetailFailureReason = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' + +/** A public CDN that serves single files of published npm package versions. */ +export type NpmFileSource = 'jsdelivr' | 'unpkg' | 'npmmirror' diff --git a/packages/util/plugin-detail/tests/document.spec.ts b/packages/util/plugin-detail/tests/document.spec.ts new file mode 100644 index 0000000000..d22719f7de --- /dev/null +++ b/packages/util/plugin-detail/tests/document.spec.ts @@ -0,0 +1,92 @@ +/** Package document selection, language chains, and image destinations. */ +import { expect, it } from 'vitest' +import { detailImage, languageChain, markdownOf, readPackageDocument } from '../src/document.ts' +import { DetailFailure, readmeName, type DetailFiles } from '../src/files.ts' + +const signal = new AbortController().signal + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + +/** In-memory files. */ +function memory(contents: Record): DetailFiles & { reads: string[] } { + const reads: string[] = [] + const read = (path: string, maxBytes: number) => { + reads.push(path) + const value = contents[path] + if (value === undefined) return Promise.resolve(undefined) + if (value.length > maxBytes) return Promise.reject(new DetailFailure('too-large', path)) + return Promise.resolve(new TextEncoder().encode(value)) + } + return { + key: 'memory', reads, read, + async readme(dir, maxBytes) { + const prefix = dir === '' ? '' : `${dir}/` + const name = readmeName(Object.keys(contents).filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length))) + if (name === undefined) return undefined + return { path: `${prefix}${name}`, bytes: (await read(`${prefix}${name}`, maxBytes)) as Uint8Array } + }, + } +} + +it('derives language chains from a language id', () => { + expect(languageChain('zh-Hans-CN')).toEqual(['zh-hans-cn', 'zh-hans', 'zh', 'en']) + expect(languageChain('en-US')).toEqual(['en-us', 'en']) + expect(languageChain('en')).toEqual(['en']) + expect(() => languageChain('../x')).toThrow('Not a language id') +}) + +it('decodes Markdown without a byte order mark or a leading front matter block', () => { + expect(markdownOf(new TextEncoder().encode('\uFEFF---\ntitle: x\n---\n# A\n\n---\nkept: rule\n---\n'))).toBe('# A\n\n---\nkept: rule\n---\n') + expect(markdownOf(new TextEncoder().encode('---\r\nk: v\r\n---'))).toBe('') + expect(markdownOf(new TextEncoder().encode('# B'))).toBe('# B') +}) + +it('reads the locale document first, falling back through languages and failing on a missing named file', async () => { + const files = memory({ + 'locale/zh.json': JSON.stringify({ meta: { title: '无详情' } }), + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'locale/zh-CN.json': JSON.stringify({ meta: { detail: 'guide/详情.md' } }), + 'guide/DETAIL.md': '# Detail', + 'guide/详情.md': '# 详情', + 'README.md': '# Readme', + }) + expect(await readPackageDocument(files, '', ['zh', 'en'], 100, signal)).toEqual({ origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Detail' }) + expect(await readPackageDocument(files, '', languageChain('zh-CN'), 100, signal)).toMatchObject({ origin: 'locale', markdown: '# 详情' }) + expect(files.reads).toContain('locale/zh-cn.json') + await readPackageDocument(files, '', languageChain('zh-Hant'), 100, signal) + expect(files.reads).not.toContain('locale/zh-HANT.json') + const missing = memory({ 'locale/en.json': JSON.stringify({ meta: { detail: 'GONE.md' } }) }) + await expectRejection(readPackageDocument(missing, '', ['en'], 100, signal), { reason: 'invalid' }, 'GONE.md') +}) + +it('reports malformed locale files and invalid detail paths, and falls back to the README without meta.detail', async () => { + const read = (contents: Record) => readPackageDocument(memory(contents), 'pkg', ['en'], 100, signal) + await expectRejection(read({ 'pkg/locale/en.json': '{' }), { reason: 'invalid' }, 'pkg/locale/en.json') + const invalid = [ + ['/README.md', 'must be a relative path inside the package'], ['../x.md', 'must be a relative path inside the package'], + ['guide/./x.md', 'must be a relative path inside the package'], ['guide//x.md', 'must be a relative path inside the package'], + ['guide\\x.md', 'must be a relative path inside the package'], ['https://example.com/x.md', 'must be a relative path inside the package'], + ['README.txt', 'must name a Markdown file'], [' ', 'must be a non-empty string'], [7, 'must be a non-empty string'], + ] as const + for (const [detail, message] of invalid) { + await expectRejection(read({ 'pkg/locale/en.json': JSON.stringify({ meta: { detail } }) }), { reason: 'invalid' }, `meta.detail ${message}`) + } + expect(await read({ 'pkg/locale/en.json': 'null', 'pkg/README': 'plain' })).toEqual({ origin: 'readme', path: 'pkg/README', markdown: 'plain' }) + expect(await read({ 'pkg/locale/en.json': '{"meta":"x"}' })).toBeUndefined() + expect(await read({ 'pkg/locale/en.json': JSON.stringify({ meta: { detail: 'D.markdown' } }), 'pkg/D.markdown': '# D' })) + .toMatchObject({ origin: 'locale', markdown: '# D' }) +}) + +it('resolves image destinations beside a document and refuses other paths', () => { + expect(detailImage('guide/DETAIL.md', 'img/a.png?raw=1#x')).toEqual({ path: 'guide/img/a.png', mediaType: 'image/png' }) + expect(detailImage('guide/DETAIL.md', '/logo.SVG')).toEqual({ path: 'logo.SVG', mediaType: 'image/svg+xml' }) + expect(detailImage('guide/DETAIL.md', './a%20b.webp')).toEqual({ path: 'guide/a b.webp', mediaType: 'image/webp' }) + expect(detailImage('README.md', 'b.jpeg')).toEqual({ path: 'b.jpeg', mediaType: 'image/jpeg' }) + for (const destination of ['../../etc/passwd.png', '..', '/', 'notes.txt', 'img\\a.png', '%E0%A4%A.png', 'a\0.png']) { + expect(detailImage('guide/DETAIL.md', destination)).toBeUndefined() + } +}) diff --git a/packages/util/plugin-detail/tests/files.spec.ts b/packages/util/plugin-detail/tests/files.spec.ts new file mode 100644 index 0000000000..288b4057a7 --- /dev/null +++ b/packages/util/plugin-detail/tests/files.spec.ts @@ -0,0 +1,160 @@ +/** Detail file sources: local package directories, bounded HTTP reads, and npm file CDNs. */ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { DetailFailure, fetchBody, localFiles, npmFileSourceOrder, npmFiles, readmeByName, readmeName } from '../src/files.ts' + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + + +let root: string +const signal = new AbortController().signal +const lifetime = new AbortController().signal +const options = { timeoutMs: 5000, lifetime } + +beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-files-')) }) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.unstubAllGlobals() +}) + +function file(path: string, contents: string): void { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), contents) +} + +const text = (bytes: Uint8Array | undefined): string | undefined => bytes === undefined ? undefined : new TextDecoder().decode(bytes) + +/** Answer requests by URL with a fresh response each time; any other URL fails the test. */ +function serve(routes: Record Response)>) { + const fetch = vi.fn((url: string) => { + const route = routes[url] + if (route === undefined) throw new Error(`unexpected fetch ${url}`) + if (route instanceof Error) return Promise.reject(route) + return Promise.resolve(route()) + }) + vi.stubGlobal('fetch', fetch) + return fetch +} + +it('reads package files and README names, and refuses paths whose real location leaves the package', async () => { + file('pkg/README.md', '# Pkg') + file('pkg/docs/readme', 'docs') + file('pkg/big.md', 'x'.repeat(20)) + file('outside.md', 'secret') + symlinkSync(join(root, 'outside.md'), join(root, 'pkg', 'link.md')) + file('escaping/x', '') + symlinkSync(join(root, 'outside.md'), join(root, 'escaping', 'README.md')) + const files = localFiles(join(root, 'pkg'), '1.0.0') + expect(files.key).toBe(`local:${join(root, 'pkg')}@1.0.0`) + expect(text(await files.read('README.md', 100, signal))).toBe('# Pkg') + expect(await files.read('missing.md', 100, signal)).toBeUndefined() + expect(await files.read('docs', 100, signal)).toBeUndefined() + expect(await files.read('link.md', 100, signal)).toBeUndefined() + await expect(files.read('big.md', 10, signal)).rejects.toMatchObject({ reason: 'too-large' }) + expect(await files.readme('', 100, signal)).toMatchObject({ path: 'README.md' }) + expect(await files.readme('docs', 100, signal)).toMatchObject({ path: 'docs/readme' }) + expect(await files.readme('missing', 100, signal)).toBeUndefined() + expect(await localFiles(join(root, 'pkg', 'docs'), '1.0.0').readme('', 100, signal)).toMatchObject({ path: 'readme' }) + expect(await localFiles(join(root, 'empty'), '1.0.0').readme('', 100, signal)).toBeUndefined() + file('noreadme/index.js', '') + expect(await localFiles(join(root, 'noreadme'), '1.0.0').readme('', 100, signal)).toBeUndefined() + expect(await localFiles(join(root, 'escaping'), '1.0.0').readme('', 100, signal)).toBeUndefined() +}) + +it('orders README names', () => { + expect(readmeName(['readme.markdown', 'README', 'Readme.md', 'readme.txt'])).toBe('Readme.md') + expect(readmeName(['readme.MD', 'README.markdown'])).toBe('README.markdown') + expect(readmeName(['readme.MD', 'Readme.MD'])).toBe('Readme.MD') + expect(readmeName(['Readme.MD', 'readme.MD'])).toBe('Readme.MD') + expect(readmeName(['index.md'])).toBeUndefined() +}) + +it('reads bodies under a limit and reports failures', async () => { + serve({ + 'https://a.example/ok': () => new Response('ok'), + 'https://a.example/empty': () => new Response(null), + 'https://a.example/missing': () => new Response('no', { status: 404 }), + 'https://a.example/declared': () => new Response('x', { headers: { 'content-length': '99' } }), + 'https://a.example/long': () => new Response('x'.repeat(20)), + 'https://a.example/down': new Error('refused'), + 'https://a.example/broken': () => new Response(new ReadableStream({ pull(controller) { controller.error(new Error('reset')) } })), + 'https://a.example/odd': () => new Response(new ReadableStream({ pull(controller) { controller.error('reset') } })), + }) + expect(await fetchBody('https://a.example/ok', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.from('ok') }) + expect(await fetchBody('https://a.example/empty', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.alloc(0) }) + expect(await fetchBody('https://a.example/missing', 10, signal, options)).toEqual({ ok: false, status: 404 }) + await expect(fetchBody('https://a.example/declared', 10, signal, options)).rejects.toMatchObject({ reason: 'too-large' }) + await expect(fetchBody('https://a.example/long', 10, signal, options)).rejects.toMatchObject({ reason: 'too-large' }) + await expectRejection(fetchBody('https://a.example/down', 10, signal, options), { reason: 'network' }, 'refused') + await expectRejection(fetchBody('https://a.example/broken', 10, signal, options), { reason: 'network' }, 'reset') + await expectRejection(fetchBody('https://a.example/odd', 10, signal, options), { reason: 'network' }, 'reset') + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- a non-Error rejection keeps its text in the failure + vi.stubGlobal('fetch', vi.fn(() => Promise.reject('plain'))) + await expectRejection(fetchBody('https://a.example/plain', 10, signal, options), { reason: 'network' }, 'plain') +}) + +it('rejects a cancelled read with the cancellation reason instead of a network failure', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('aborted')))) + const cancelled = new AbortController() + cancelled.abort(new Error('closed by caller')) + await expect(fetchBody('https://a.example/x', 10, cancelled.signal, options)).rejects.toThrow('closed by caller') + const disposed = new AbortController() + disposed.abort(new Error('service disposed')) + await expect(fetchBody('https://a.example/x', 10, signal, { ...options, lifetime: disposed.signal })).rejects.toThrow('service disposed') +}) + +it('follows redirects within the origin only', async () => { + serve({ + 'https://a.example/old': () => new Response(null, { status: 301, headers: { location: '/new' } }), + 'https://a.example/new': () => new Response('moved'), + 'https://a.example/away': () => new Response(null, { status: 302, headers: { location: 'https://b.example/x' } }), + 'https://a.example/loop': () => new Response(null, { status: 302, headers: { location: '/loop' } }), + 'https://a.example/bare': () => new Response(null, { status: 302 }), + }) + expect(await fetchBody('https://a.example/old', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.from('moved') }) + expect(await fetchBody('https://a.example/away', 10, signal, options)).toEqual({ ok: false, status: 302 }) + expect(await fetchBody('https://a.example/loop', 10, signal, options)).toEqual({ ok: false, status: 302 }) + expect(await fetchBody('https://a.example/bare', 10, signal, options)).toEqual({ ok: false, status: 302 }) +}) + +it('reads npm files from the CDNs in order, trusting a 404 and asking the next CDN after a failure', async () => { + const fetch = serve({ + 'https://cdn.jsdelivr.net/npm/@a/b@1.0.0/docs/A%20B.md': new Error('offline'), + 'https://unpkg.com/@a/b@1.0.0/docs/A%20B.md': () => new Response('fail', { status: 502 }), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/docs/A%20B.md': () => new Response('# AB'), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/README.md': () => new Response('no', { status: 404 }), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/readme.md': () => new Response('# readme'), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/big.md': () => new Response('x'.repeat(20)), + 'https://cdn.jsdelivr.net/npm/@a/b@1.0.0/down.md': new Error('offline'), + }) + const files = npmFiles('@a/b', '1.0.0', ['jsdelivr', 'unpkg', 'npmmirror'], options) + expect(files.key).toBe('npm:@a/b@1.0.0') + expect(text(await files.read('docs/A B.md', 100, signal))).toBe('# AB') + const mirror = npmFiles('@a/b', '1.0.0', ['npmmirror'], options) + expect(await mirror.readme('', 100, signal)).toMatchObject({ path: 'readme.md' }) + await expect(mirror.read('big.md', 10, signal)).rejects.toMatchObject({ reason: 'too-large' }) + const failure = npmFiles('@a/b', '1.0.0', ['jsdelivr'], options).read('down.md', 10, signal) + await expect(failure).rejects.toBeInstanceOf(DetailFailure) + await expectRejection(failure, { reason: 'network' }, 'offline') + expect(fetch).toHaveBeenCalledTimes(7) +}) + +it('reads the first README name that exists', async () => { + const read = (path: string) => Promise.resolve(path === 'd/readme.md' ? Buffer.from('r') : undefined) + expect(await readmeByName(read, 'd', 10, signal)).toMatchObject({ path: 'd/readme.md' }) + expect(await readmeByName(read, 'd', 10, signal, ['README.md'])).toBeUndefined() +}) + +it('orders the configured npm CDNs by a preference, ignoring CDNs that are not configured', () => { + const all = ['jsdelivr', 'unpkg', 'npmmirror'] as const + expect(npmFileSourceOrder(all, ['npmmirror'])).toEqual(['npmmirror', 'jsdelivr', 'unpkg']) + expect(npmFileSourceOrder(all, ['unpkg', 'npmmirror'])).toEqual(['unpkg', 'npmmirror', 'jsdelivr']) + expect(npmFileSourceOrder(all)).toEqual(['jsdelivr', 'unpkg', 'npmmirror']) + expect(npmFileSourceOrder(['unpkg'], ['npmmirror', 'other'])).toEqual(['unpkg']) +}) diff --git a/packages/util/plugin-detail/tsconfig.json b/packages/util/plugin-detail/tsconfig.json new file mode 100644 index 0000000000..0b174e83f6 --- /dev/null +++ b/packages/util/plugin-detail/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ] +} diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index b49ed1f8e7..cad952bcf0 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -3,32 +3,41 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/time/README.md /: - en: 1816778111548561 - zh: 460c67cc44e59372 + en: 6daf8db648646a31 + zh: 156a7d47413a6a55 /dsh-util-time: en: bc299a9b8fe7717f zh: 896661ee0c4edf25 /dsh-util-time/summary: - en: f37c9eb6571622ac - zh: d782978ff11bd40f + en: cdc849b4f44094b1 + zh: b03edaa6a020d39d /dsh-util-time/table-of-contents: - en: cd2a60a905c978d6 - zh: cc71d387d0cbd442 + en: d152484eb41ac6b4 + zh: f14f34fce35a8d90 /dsh-util-time/use-this-package: - en: 1485e3ff508a8960 - zh: 47ba622434bd2625 -/dsh-util-time/api: - en: cbb4b726e939fc03 - zh: e9df4a7487433146 + en: bbff250eba0f1b89 + zh: 0a618f05cc88ee2b +/dsh-util-time/use-this-package/when-to-use-it: + en: 85291d5fb4c9000b + zh: d1717c3b01efb5d0 +/dsh-util-time/use-this-package/entry-point: + en: 7ca7ddebcb6af6be + zh: 4702a29079d6b8a7 +/dsh-util-time/understand-the-implementation: + en: 14595019b72aa45f + zh: cfea261931019621 +/dsh-util-time/further-exploration: + en: 8aa95ebdfd1f323e + zh: c6879eac9fa6e087 /dsh-util-time/model-experience: - en: 02d997b5c1318f27 - zh: 5c55d060adea756d + en: 3ea15a23d9901c72 + zh: c39eb7d446dd13af /dsh-util-time/model-experience/kv-cache-effect: - en: f63edbd9621eee70 - zh: 5e3982508b32b039 + en: daf6e45a18c087b0 + zh: e6714f1e65ed55ce /dsh-util-time/known-limitations-and-deferred-work: - en: 130db809b9a6db90 - zh: 5b0b72ada5b131dd + en: 854d5bf170c91bba + zh: b5544e6bd1319d90 /dsh-util-time/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/util/time/README.md b/packages/util/time/README.md index 40f6b4d9ef..c86f1c7462 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -1,5 +1,5 @@ --- -description: "IANA time-zone validation and canonicalization for maintainers accepting a caller-reported zone at a wire boundary." +description: "Strict RFC 3339 instant, local-calendar, and IANA zone parsing, plus a millisecond span type, for maintainers accepting a caller-reported zone, a wall-clock selector, or an elapsed time at a boundary." kind: "package-library" --- @@ -9,12 +9,13 @@ English | [中文](README.zh.md) ## Summary -Zero-dependency zone vocabulary for the wire boundaries that accept a caller's time zone. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling of it, so an alias never reaches a durable record: a zone identity is stored on messages and re-derived later by another process, where an alias would not compare equal. The library validates and canonicalizes only — it formats no time and owns no failure vocabulary, because each boundary throws its own domain code. +Shared time vocabulary for the boundaries that accept a caller's zone, a wall-clock selector, or an elapsed time. `canonicalClientTimeZone` answers the platform-canonical `UTC` or IANA `Area/Location` spelling, so an alias never reaches a durable record. The strict parsers read RFC 3339 instants, local calendar fields, and zone names as discriminated results, and `resolveLocalInstant` applies maintained `Temporal` DST and overlap behavior. `Duration` holds a span in milliseconds and splits it into whole parts. The library owns no failure vocabulary and no model-facing text: each caller maps a reason onto its own domain code. ## Table of Contents - [Use this package](#use-this-package) -- [API](#api) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) @@ -24,38 +25,88 @@ Zero-dependency zone vocabulary for the wire boundaries that accept a caller's t ## Use this package +### When to use it + It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state. -Call it at the boundary that receives the zone, before the value reaches anything durable. An unusable name answers `undefined`, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. +Call it at the boundary that receives the zone or the wall-clock selector, before the value reaches anything durable. `dsh-api-session-controller` and `dsh-subagent` admit a caller-reported zone through `canonicalClientTimeZone`; `dsh-time-context` admits the zone a request turn reports through `canonicalizeTimeZone` and refuses an alias rather than rewriting it; `dsh-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. `dsh-time-context` reads its elapsed reading through `Duration` and keeps the wording of the line it injects. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value. + + +### Entry point + +```ts +import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time' + +const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined +const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason +const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accepted', time } or a rejection reason + +if (local.kind === 'accepted') { + resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap +} + +Duration.fromMilliseconds(1_500).decompose() // { days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 500 } +``` + +`canonicalClientTimeZone` answers `undefined` for a name it refuses, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. `canonicalizeTimeZone`, the strict parsers, and `fourDigitYearEpoch` instead report why they refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` take already-validated values and answer a string, or epoch milliseconds, with `undefined` for an unrepresentable epoch or a wall-clock value inside a DST gap. `Duration` is a frozen value type over one millisecond count: it validates that the count is finite and inside the exactly representable range, and splits a non-negative span into whole parts. It publishes only the operations its consumers call; a member for another unit or for span arithmetic lands with the caller that needs it. ----- - -## API + +## Understand the implementation -```ts -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' -``` +
    +Implementation internals — click to expand -| Export | Role | +`src/instant.ts` holds the parsers and `src/duration.ts` the span; `src/index.ts` re-exports both and adds `canonicalClientTimeZone`, the `string | undefined` form the wire boundaries already consume. The module JSDoc there states each entry's accepted profile and every reason. + +The strict parsers match a fixed regular expression before constructing any date, so a malformed spelling, an out-of-range clock field, and a date the calendar does not have answer distinct reasons. Local resolution defers to `@js-temporal/polyfill`: `disambiguation: 'earlier'` picks the first instant of an overlap, and the local-field round trip answers `undefined` for a value inside a forward transition's gap. + +| Entry | Accepted input | |---|---| -| `canonicalClientTimeZone(value)` | Canonical `UTC` or IANA `Area/Location` name for an accepted zone, `undefined` for a blank, padded, abbreviated, single-segment, or platform-unsupported one. | +| `canonicalClientTimeZone` | `UTC` or an IANA `Area/Location` name; answers `undefined` when refused. | +| `canonicalizeTimeZone` | The same names; answers the canonical spelling or a refusal reason, and carries the platform error when the platform refused a well-formed name. | +| `parseLocalCalendar` | `YYYY-MM-DD` and `HH:mm:ss`, with optional one-to-three fractional digits. | +| `parseOffsetInstant` | RFC 3339 with an explicit `Z` or numeric offset; the offset is part of the input. | +| `fourDigitYearEpoch` | The canonical stored profile: exactly three fractional digits and a `Z`. | +| `formatFourDigitYearInstant` | An integral epoch inside the four-digit-year range; answers `undefined` outside it or when non-integral. | +| `isFourDigitYearInstant` | The same range, as a predicate, so a caller that only needs the bound does not re-derive it. | +| `resolveLocalInstant` | Exact calendar fields of a real date, or an already-built `Temporal.PlainDateTime`, plus an already-canonical zone. | +| `Duration` | One finite millisecond count inside the exactly representable range, and its split into whole days, hours, minutes, seconds, and the remaining fraction. | + +
    + +----- + + +## Further Exploration + +- [Schedule Host storage](../../schedule/schedule/README.md) — the consumer that stores committed UTC targets. +- [Session controller](../../api/session-controller/README.md) — the request boundary that canonicalizes a browser zone. +- [Time context](../../context/time-context/README.md) — the request consumer that refuses a non-canonical browser zone. +- [util/ package map](../README.md) — where this library sits among the shared utilities. + +----- ## Model Experience -Indirectly, through the consumer that records a canonical zone on a durable message, from which `dsh-time-context` renders the turn's model-visible zone instruction and timestamp. +Indirectly, through the consumers that store a canonical zone or a committed UTC instant on durable records: `dsh-time-context` renders the turn's model-visible zone instruction and timestamp from the zone, and `dsh-schedule` renders its model-visible scheduler text from the committed instant. #### KV Cache effect -None of its own. The consumer that injects a zone-derived line into a request owns that request's cache behavior. +None of its own. The consumer that injects a zone- or schedule-derived line into a request owns that request's cache behavior. ## Known Limitations and Deferred Work - **Alias resolution follows the runtime's ICU data** — which name an alias group canonicalizes to is the platform's answer, so two processes on different Node builds can disagree about it. -- **Validation only** — no formatting, offset arithmetic, DST reasoning, or instant conversion; consumers needing those use `Intl` directly. +- **No recurrence arithmetic** — instant validity, local-calendar parsing, zone resolution, canonical formatting, and a millisecond span only; a caller that walks candidate dates or intervals owns that search. +- **`Duration` has no calendar units** — a day is exactly 24 hours and a month has no fixed length, so a caller that needs local calendar days derives them from the zone. +- **`Duration` publishes only what a consumer calls** — `fromMilliseconds` and `decompose()`; a conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it, so the published contract grows with a caller rather than ahead of one. +- **Seconds-unit spans still multiply** — `dsh-schedule`'s interval and delay fields and the DeepSeek file store's refresh margin are second-valued spans converted with `* 1_000`; none has moved onto this library, and an epoch expressed in seconds is not a span and must not be built into one. +- **The `Temporal` polyfill is a runtime dependency** — a consumer that imports only `canonicalClientTimeZone` still resolves it at module load, because both entries share one module. ### Dev Note diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index d58f52853c..cecc42686f 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向在协议边界接收调用方所报时区的维护者,说明 IANA 时区校验与规范化。" +description: "面向在协议边界接收调用方所报时区、墙钟选择器或耗时的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析,以及毫秒精度的时间段类型。" kind: "package-library" --- @@ -9,12 +9,13 @@ kind: "package-library" ## 概述 -零依赖的时区词汇,供接收调用方时区的协议边界使用。`canonicalClientTimeZone` 只接受 `UTC` 或 IANA `Area/Location` 名称,并回答该名称在当前平台上的规范拼写,因此别名不会进入持久记录:时区标识会存储在消息中,并由另一个进程稍后重新推导;别名在该进程中无法与规范名称比较为相等。本库只做校验与规范化——不格式化任何时间,也不持有失败词汇,因为每个边界抛自己的域码。 +共享时间词汇,供接收调用方时区、墙钟选择器或耗时的边界使用。`canonicalClientTimeZone` 回答当前平台上的规范 `UTC` 或 IANA `Area/Location` 拼写,因此别名不会进入持久记录。严格解析器以带判别值的结果读取 RFC 3339 时刻、本地日历字段与时区名称,`resolveLocalInstant` 则套用受维护的 `Temporal` 的 DST 与重叠行为。`Duration` 以毫秒保存一段时间并拆成整数量。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。 ## 目录 - [使用本包](#use-this-package) -- [API](#api) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -24,38 +25,88 @@ kind: "package-library" ## 使用本包 +### 何时使用 + 它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 -在接收时区的那个边界上调用它,让值在进入任何持久物之前先过一遍。不可用的名称回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。 +在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。`dsh-time-context` 通过 `Duration` 读取其耗时读数,并保留它注入那一行的措辞。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 + + +### 入口 + +```ts +import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time' + +const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined +const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason +const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accepted', time } or a rejection reason + +if (local.kind === 'accepted') { + resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap +} + +Duration.fromMilliseconds(1_500).decompose() // { days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 500 } +``` + +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限且落在可精确表示的范围内,并把非负的时间段拆成整数量。它只发布消费方会调用的部分;面向其它单位或时间段算术的成员都随第一个需要它的调用方落地。 ----- - -## API + +## 理解实现 -```ts -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' -``` +
    +实现内部细节 —— 点击展开 -| 导出 | 职责 | +`src/instant.ts` 承载各解析器,`src/duration.ts` 承载时间段;`src/index.ts` 转出两者,并新增 `canonicalClientTimeZone`——协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。 + +严格解析器先匹配固定正则,再构造任何日期,因此拼写错误、越界时钟字段与日历上不存在的日期分别回答不同的原因。本地解析交给 `@js-temporal/polyfill`:`disambiguation: 'earlier'` 取重叠时段中的第一个时刻,而本地字段往返比对对前向跳变缺口内的值回答 `undefined`。 + +| 入口 | 接受的输入 | |---|---| -| `canonicalClientTimeZone(value)` | 对接受的时区回答规范的 `UTC` 或 IANA `Area/Location` 名称;空串、带空白、缩写、单段或平台不支持的名称回答 `undefined`。 | +| `canonicalClientTimeZone` | `UTC` 或 IANA `Area/Location` 名称;被拒绝时回答 `undefined`。 | +| `canonicalizeTimeZone` | 同样的名称;回答规范拼写或拒绝原因;平台拒绝了一个格式合法的名称时,还会携带该平台错误。 | +| `parseLocalCalendar` | `YYYY-MM-DD` 与 `HH:mm:ss`,可带 1 到 3 位小数秒。 | +| `parseOffsetInstant` | 带显式 `Z` 或数字偏移的 RFC 3339;偏移量属于输入的一部分。 | +| `fourDigitYearEpoch` | 规范存储剖面:恰好 3 位小数秒与 `Z`。 | +| `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 | +| `isFourDigitYearInstant` | 同一范围的谓词形式,只判断范围边界的调用方无需自行推导。 | +| `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 | +| `Duration` | 一个落在可精确表示范围内的毫秒计数,以及它拆出的整日、整时、整分、整秒与不足一秒的余数。 | + +
    + +----- + + +## 延伸阅读 + +- [Schedule 宿主存储](../../schedule/schedule/README.zh.md) —— 保存已提交 UTC 时点的消费方。 +- [Session 控制器](../../api/session-controller/README.zh.md) —— 规范化浏览器时区的请求边界。 +- [时间上下文](../../context/time-context/README.zh.md) —— 拒绝非规范浏览器时区的请求消费方。 +- [util/ 包地图](../README.zh.md) —— 本库在共享工具中的位置。 + +----- ## 模型体验 -间接影响,取决于把规范时区记到持久消息上的那个消费方——`dsh-time-context` 据此渲染该轮模型可见的时区指令与时间戳。 +间接影响,取决于把规范时区或已提交 UTC 时刻记到持久记录上的那些消费方:`dsh-time-context` 据时区渲染本轮模型可见的时区指令与时间戳,`dsh-schedule` 据已提交时刻渲染其模型可见的调度文本。 #### KV Cache 影响 -自身没有。把时区派生文本注入请求的那个消费方,对该请求的缓存行为负责。 +自身没有。把时区或调度的派生文本注入请求的那个消费方,对该请求的缓存行为负责。 ## 已知限制与延期工作 - **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 -- **只做校验**——不格式化、不做偏移运算、不推导 DST、不做时刻换算;需要这些的消费方直接用 `Intl`。 +- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。 +- **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。 +- **`Duration` 只发布消费方会调用的部分**——`fromMilliseconds` 与 `decompose()`;某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地,公开契约跟着调用方长,而不是长在它前面。 +- **以秒为单位的跨度仍在自行相乘**——`dsh-schedule` 的间隔与延迟字段、DeepSeek 文件存储的刷新余量都是以秒为单位的跨度,用 `* 1_000` 换算;它们都还没迁移到本库,而以秒表示的纪元不是跨度,不得构造成跨度。 +- **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。 ### 开发备注 diff --git a/packages/util/time/package.json b/packages/util/time/package.json index dbcd9b63f8..79f454f7fc 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-util-time", - "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", + "description": "Time vocabulary shared by the boundaries that accept a caller's zone, a wall-clock selector, or an elapsed time: canonicalClientTimeZone (IANA zone validation and canonicalization), strict RFC 3339 instant, local-calendar, and zone resolution, and a millisecond Duration span", "version": "0.2.1-rc.2", "publishConfig": { "access": "public" @@ -26,6 +26,9 @@ "lib/types/**/*.d.ts" ], "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "0.5.1" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" }, diff --git a/packages/util/time/src/duration.ts b/packages/util/time/src/duration.ts new file mode 100644 index 0000000000..7543d299dc --- /dev/null +++ b/packages/util/time/src/duration.ts @@ -0,0 +1,110 @@ +/** + * One span of time, held as a count of milliseconds and split into the whole + * parts a reading is written in. + * + * A class rather than a branded number: arithmetic on two branded numbers + * answers an unbranded `number`, so a brand cannot stop seconds and + * milliseconds from being added, while an instance can. The published surface + * is the set the migrated consumers call; a further member lands with the caller + * that needs it. + * @module @deepseek-ai/dsh-util-time + */ + +const MS_PER_SECOND = 1_000 +const SECONDS_PER_DAY = 86_400 +const SECONDS_PER_HOUR = 3_600 +const SECONDS_PER_MINUTE = 60 + +/** Whole parts of one non-negative span, in the units a reading is written in. */ +export interface DurationParts { + /** Whole days. */ + readonly days: number + /** Whole hours left after the days, from 0 through 23. */ + readonly hours: number + /** Whole minutes left after the hours, from 0 through 59. */ + readonly minutes: number + /** Whole seconds left after the minutes, from 0 through 59. */ + readonly seconds: number + /** Fractional milliseconds left after the seconds, from 0 up to (but excluding) 1000. */ + readonly milliseconds: number +} + +/** + * Largest millisecond count a span holds. + * + * Every integer up to this bound is exactly representable, which is what makes + * the whole-part split exact; beyond it the parts a reader gets back would not + * add up to the span. + */ +const MAX_SPAN_MS = Number.MAX_SAFE_INTEGER + +/** + * One span of time in milliseconds. + * + * Instances are frozen, so a span cannot drift after it is built. A span is + * signed: a negative count builds, because the clock difference that produced it + * is real, but `decompose()` refuses it because a negative interval has no single + * whole-part split. A caller that reports parts therefore decides its own policy + * first, and the elapsed reading clamps a backward clock reading to zero before + * it builds the span. A span is bounded by the exactly representable millisecond + * range, which is wider than any interval this harness schedules, and it carries + * no calendar meaning: a month is not a fixed count of milliseconds. + */ +export class Duration { + private readonly span: number + + private constructor(milliseconds: number) { + this.span = milliseconds + Object.freeze(this) + } + + /** + * Build one span from a millisecond count. + * A negative count is accepted, so a caller can hold a backward clock difference; + * such a span cannot be decomposed, and the caller states its own sign policy. + * @param milliseconds - Signed count within the exactly representable range (about 285,000 years); fractions are kept. + * @returns The span. + * @throws TypeError when the count is not finite, or exceeds the exactly representable millisecond range. + */ + static fromMilliseconds(milliseconds: number): Duration { + if (!Number.isFinite(milliseconds)) { + throw new TypeError(`duration milliseconds must be finite, got ${String(milliseconds)}`) + } + if (Math.abs(milliseconds) > MAX_SPAN_MS) { + throw new TypeError( + `duration milliseconds must not exceed ${MAX_SPAN_MS} so its parts stay exact, got ${String(milliseconds)}`, + ) + } + return new Duration(milliseconds) + } + + /** + * Split the span into whole days, hours, minutes, seconds, and the remaining + * fraction of a second. Days, hours, minutes, and seconds are floored; the + * millisecond part keeps the fraction below one second. Every span this class + * accepts is inside the exactly representable millisecond range, so the parts + * add back up to the span. + * @returns The frozen whole parts. + * @throws RangeError when the span is negative, because a negative interval + * has no single whole-part decomposition: the caller owns that policy. + */ + decompose(): DurationParts { + if (this.span < 0) { + throw new RangeError('a negative duration has no whole-part decomposition') + } + let seconds = Math.floor(this.span / MS_PER_SECOND) + const days = Math.floor(seconds / SECONDS_PER_DAY) + seconds %= SECONDS_PER_DAY + const hours = Math.floor(seconds / SECONDS_PER_HOUR) + seconds %= SECONDS_PER_HOUR + const minutes = Math.floor(seconds / SECONDS_PER_MINUTE) + seconds %= SECONDS_PER_MINUTE + return Object.freeze({ + days, + hours, + minutes, + seconds, + milliseconds: this.span - Math.floor(this.span / MS_PER_SECOND) * MS_PER_SECOND, + }) + } +} diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index 1867965cf2..31d50fda3f 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -1,12 +1,33 @@ /** - * Time vocabulary shared by the wire boundaries that accept a caller's zone. - * Validation and canonicalization only: this library formats nothing and owns - * no failure vocabulary — each boundary declares and throws its own refusal. + * Time vocabulary shared by the boundaries that accept a caller's zone or a + * wall-clock selector, plus one span type for elapsed and interval arithmetic. + * + * The library validates, canonicalizes, parses, and renders one canonical instant + * format; it produces no model-facing text and owns no failure vocabulary. A refusal that carries a + * reason is a discriminated result, and each caller maps that reason onto its + * own domain code and diagnostic. `canonicalClientTimeZone` is the wire-boundary + * form and answers a name or `undefined`; `formatFourDigitYearInstant` and + * `resolveLocalInstant` take already-validated values and answer a string or + * epoch milliseconds directly; `Duration` holds a span in milliseconds with no + * implicit numeric conversion. * @module @deepseek-ai/dsh-util-time */ -/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ +import { canonicalizeTimeZone } from './instant.ts' + +export { Duration } from './duration.ts' +export { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + isFourDigitYearInstant, + parseLocalCalendar, + parseOffsetInstant, + resolveLocalInstant, +} from './instant.ts' +export type { InstantRejection, LocalCalendarRejection, LocalCalendarTime, TimeZoneRejection } from './instant.ts' /** * Validate and canonicalize one caller-supplied IANA zone at a wire boundary. @@ -18,16 +39,6 @@ const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ * @returns the canonical zone, or `undefined` when the name is unusable. */ export function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) - .resolvedOptions().timeZone - /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ - if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined - return canonical - } catch { - // Intl rejects unsupported zone names; the caller maps that parser rejection. - return undefined - } + const resolved = canonicalizeTimeZone(value) + return resolved.kind === 'accepted' ? resolved.timeZone : undefined } diff --git a/packages/util/time/src/instant.ts b/packages/util/time/src/instant.ts new file mode 100644 index 0000000000..60aac90ffb --- /dev/null +++ b/packages/util/time/src/instant.ts @@ -0,0 +1,313 @@ +/** + * Strict instant and local-calendar vocabulary for the boundaries that accept a + * wall-clock selector. + * + * Every entry that reads untrusted text answers a discriminated result instead + * of throwing: the library carries no failure vocabulary, and each caller maps + * a reason onto its own domain code and diagnostic. `formatFourDigitYearInstant` + * and `isFourDigitYearInstant` never throw, while `resolveLocalInstant` raises a + * `RangeError` when a caller violates its precondition — an unknown zone, or + * calendar fields naming a value the calendar does not have. Local wall-clock + * resolution uses the maintained `Temporal` implementation, so no caller owns + * offset sampling or DST rules. + * @module @deepseek-ai/dsh-util-time + */ + +import { Temporal } from '@js-temporal/polyfill' + +/** Inclusive lower epoch-millisecond bound of a four-digit-year RFC 3339 UTC instant. */ +export const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z') + +/** Inclusive upper epoch-millisecond bound of a four-digit-year RFC 3339 UTC instant. */ +export const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z') + +/** Canonical four-digit-year RFC 3339 UTC instant with mandatory milliseconds. */ +const UTC_INSTANT = + /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/ + +/** RFC 3339 instant with mandatory date, time, and explicit zone; fraction is optional. */ +const OFFSET_INSTANT = new RegExp( + String.raw`^(?\d{4})-(?\d{2})-(?\d{2})` + + String.raw`T(?\d{2}):(?\d{2}):(?\d{2})` + + String.raw`(?:\.(?\d{1,3}))?(?Z|(?[+-])` + + String.raw`(?\d{2}):(?\d{2}))$`, +) + +/** Four-digit ISO calendar date. */ +const LOCAL_DATE = /^(?\d{4})-(?\d{2})-(?\d{2})$/ + +/** Local wall-clock time with optional one-to-three digit fractional seconds. */ +const LOCAL_TIME = /^(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d{1,3}))?$/ + +/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +/** Exact calendar fields of one local wall-clock value. */ +export interface LocalCalendarTime { + /** + * Year of the calendar date. `parseLocalCalendar` rejects year 0 in untrusted + * text; `resolveLocalInstant` accepts any year a real `Temporal` date carries, + * so a caller that needs the four-digit bound filters the answered epoch. + */ + readonly year: number + /** Month from 1 through 12. */ + readonly month: number + /** Day of month from 1 through the month's length. */ + readonly day: number + /** Hour from 0 through 23. */ + readonly hour: number + /** Minute from 0 through 59. */ + readonly minute: number + /** Second from 0 through 59; leap seconds are rejected. */ + readonly second: number + /** Millisecond from 0 through 999. */ + readonly millisecond: number +} + +/** Why an RFC 3339 instant string is unusable. */ +export type InstantRejection = + | 'not_rfc3339' + /** A field outside its calendar range: year 0, or an hour, minute, or second above its maximum. */ + | 'out_of_range' + | 'not_a_calendar_date' + | 'invalid_numeric_offset' + +/** Why a local calendar selector is unusable. */ +export type LocalCalendarRejection = 'malformed' | 'out_of_range' | 'not_a_calendar_date' + +/** Why one IANA zone selector is unusable. */ +export type TimeZoneRejection = + | 'blank' + | 'padded' + | 'not_area_location' + | 'unsupported' + | 'not_canonical_shape' + +/** Discriminated result of parsing one RFC 3339 instant string. */ +export type InstantResult = + | { readonly kind: 'accepted'; readonly epoch: number } + | { readonly kind: 'rejected'; readonly reason: InstantRejection } + +/** Discriminated result of parsing one local calendar selector. */ +export type LocalCalendarResult = + | { readonly kind: 'accepted'; readonly time: LocalCalendarTime } + | { readonly kind: 'rejected'; readonly reason: LocalCalendarRejection } + +/** Discriminated result of resolving one raw IANA zone selector. */ +export type TimeZoneResult = + | { readonly kind: 'accepted'; readonly timeZone: string } + | { + readonly kind: 'rejected' + readonly reason: TimeZoneRejection + /** Platform error caught while resolving the name; present when the platform refused a well-formed one. */ + readonly cause?: unknown + } + +/** Reject with one stable reason. */ +function rejected(reason: Reason): { kind: 'rejected'; reason: Reason } { + return Object.freeze({ kind: 'rejected', reason } as const) +} + +/** Read one required named regular-expression group as a number. */ +function groupNumber(groups: Record, name: string): number { + const value = groups[name] + /* v8 ignore next -- a successful fixed regex always provides every requested group. */ + if (value === undefined) throw new TypeError(`missing required regex group ${name}`) + return Number(value) +} + +/** Normalize an optional one-to-three digit fractional second to milliseconds. */ +function fractionalMilliseconds(value: string | undefined): number { + return value === undefined ? 0 : Number(value.padEnd(3, '0')) +} + +/** Whether an integral epoch is inside the four-digit-year range. */ +function inFourDigitYearRange(epoch: number): boolean { + return Number.isSafeInteger(epoch) && epoch >= MIN_FOUR_DIGIT_YEAR_MS && epoch <= MAX_FOUR_DIGIT_YEAR_MS +} + +/** + * Build one plain date-time while refusing a date the calendar does not have. + * + * Every field is already range-checked by the caller, so the only refusal left + * is a non-existent calendar date such as February 30. + * @param time - Calendar fields with every numeric field in range. + * @returns The plain date-time, or `undefined` when the date does not exist. + */ +function plainDateTime(time: LocalCalendarTime): Temporal.PlainDateTime | undefined { + try { + return Temporal.PlainDateTime.from(time, { overflow: 'reject' }) + } catch (error: unknown) { + /* v8 ignore next -- a rejected overflow answers a RangeError, so the false path is unreachable. */ + if (error instanceof RangeError) return undefined + /* v8 ignore next -- reached only when Temporal throws something other than a RangeError. */ + throw error + } +} + +/** + * Read one local calendar selector strictly, without consulting a process zone. + * + * The date and time texts are matched separately, so a malformed spelling, a + * non-existent calendar date such as February 30, and an out-of-range clock + * field answer distinct reasons. + * @param date - Candidate `YYYY-MM-DD` date text. + * @param time - Candidate `HH:mm:ss` time text with optional 1-3 fractional digits. + * @returns The parsed calendar fields, or the reason they are unusable. + */ +export function parseLocalCalendar(date: string, time: string): LocalCalendarResult { + const dateMatch = LOCAL_DATE.exec(date) + const timeMatch = LOCAL_TIME.exec(time) + const dateGroups = dateMatch?.groups + const timeGroups = timeMatch?.groups + if (dateGroups === undefined || timeGroups === undefined) return rejected('malformed') + const parts: LocalCalendarTime = { + year: groupNumber(dateGroups, 'year'), + month: groupNumber(dateGroups, 'month'), + day: groupNumber(dateGroups, 'day'), + hour: groupNumber(timeGroups, 'hour'), + minute: groupNumber(timeGroups, 'minute'), + second: groupNumber(timeGroups, 'second'), + millisecond: fractionalMilliseconds(timeGroups['fraction']), + } + if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { + return rejected('out_of_range') + } + if (plainDateTime(parts) === undefined) return rejected('not_a_calendar_date') + return Object.freeze({ kind: 'accepted', time: parts }) +} + +/** + * Parse one RFC 3339 instant whose numeric offset is part of the input. + * + * A bare `Z` and an explicit numeric offset are both accepted, and the offset is + * subtracted from the local wall-clock value. A negative zero offset, an offset + * whose hour or minute is out of range, and a non-existent calendar date are refused. + * @param value - Candidate instant text. + * @returns The resolved epoch milliseconds, or the reason the text is unusable. + */ +export function parseOffsetInstant(value: string): InstantResult { + const groups = OFFSET_INSTANT.exec(value)?.groups + if (groups === undefined) return rejected('not_rfc3339') + const parts: LocalCalendarTime = { + year: groupNumber(groups, 'year'), + month: groupNumber(groups, 'month'), + day: groupNumber(groups, 'day'), + hour: groupNumber(groups, 'hour'), + minute: groupNumber(groups, 'minute'), + second: groupNumber(groups, 'second'), + millisecond: fractionalMilliseconds(groups['fraction']), + } + if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { + return rejected('out_of_range') + } + const local = plainDateTime(parts) + if (local === undefined) return rejected('not_a_calendar_date') + const localEpoch = local.toZonedDateTime('UTC').epochMilliseconds + if (groups['zone'] === 'Z') return Object.freeze({ kind: 'accepted', epoch: localEpoch }) + const offsetHour = groupNumber(groups, 'offsetHour') + const offsetMinute = groupNumber(groups, 'offsetMinute') + if (offsetHour > 23 || offsetMinute > 59 + || (groups['sign'] === '-' && offsetHour === 0 && offsetMinute === 0)) { + return rejected('invalid_numeric_offset') + } + const direction = groups['sign'] === '+' ? 1 : -1 + return Object.freeze({ + kind: 'accepted', + epoch: localEpoch - direction * (offsetHour * 60 + offsetMinute) * 60_000, + }) +} + +/** + * Validate one canonical four-digit-year RFC 3339 UTC instant. + * + * The accepted profile carries exactly three fractional digits and a `Z`, and + * every year from 0001 through 9999 whose calendar date exists is representable. + * @param value - Untrusted candidate instant. + * @returns The epoch milliseconds, or the reason the text is not the canonical profile. + */ +export function fourDigitYearEpoch(value: string): InstantResult { + if (!UTC_INSTANT.test(value)) return rejected('not_rfc3339') + const epoch = Date.parse(value) + if (!Number.isFinite(epoch) || !inFourDigitYearRange(epoch) || new Date(epoch).toISOString() !== value) { + return rejected('not_a_calendar_date') + } + return Object.freeze({ kind: 'accepted', epoch }) +} + +/** + * Whether an epoch is representable as a canonical four-digit-year RFC 3339 UTC instant. + * + * Callers that only need the bound use this instead of re-deriving it from the + * two constants, so the range has one owner. + * @param epoch - Candidate epoch milliseconds. + * @returns Whether the epoch is a safe integer inside the four-digit-year range. + */ +export function isFourDigitYearInstant(epoch: number): boolean { + return inFourDigitYearRange(epoch) +} + +/** + * Format one epoch as the canonical four-digit-year RFC 3339 UTC instant. + * + * The bound check runs first, so a caller that also compares the result with a + * decision time never formats an unrepresentable instant. + * @param epoch - Candidate epoch milliseconds; a non-integral value is unrepresentable. + * @returns The canonical instant text, or `undefined` when the epoch is unrepresentable. + */ +export function formatFourDigitYearInstant(epoch: number): string | undefined { + if (!inFourDigitYearRange(epoch)) return undefined + const instant = new Date(epoch).toISOString() + /* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */ + if (!UTC_INSTANT.test(instant)) return undefined + return instant +} + +/** + * Validate and canonicalize one raw IANA zone selector. + * + * A zone identity is stored on durable records and resolved again by another + * process, so an alias is answered by its platform-canonical name. Abbreviations, + * numeric offsets, single-segment names, and unknown names are refused. + * @param value - Candidate `UTC` or IANA Area/Location name. + * @returns The canonical zone, or the reason the name is unusable. + */ +export function canonicalizeTimeZone(value: string): TimeZoneResult { + if (value.length === 0) return rejected('blank') + if (value.trim() !== value) return rejected('padded') + if (value !== 'UTC' && !IANA_TIME_ZONE.test(value)) return rejected('not_area_location') + let canonical: string + try { + canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone + } catch (error: unknown) { + // Intl rejects a well-formed name the platform's IANA data does not know; + // the caught error is the caller's only evidence for its own diagnostic. + return Object.freeze({ kind: 'rejected', reason: 'unsupported', cause: error } as const) + } + /* v8 ignore next -- Intl answers UTC or a canonical IANA Area/Location for every accepted name. */ + if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return rejected('not_canonical_shape') + return Object.freeze({ kind: 'accepted', timeZone: canonical }) +} + +/** + * Resolve one local wall-clock value in an explicit zone. + * + * A value inside a forward transition's gap does not exist, so it answers + * `undefined`; a value inside an overlap resolves to the earlier instant, which + * is the same choice the maintained `Temporal` implementation makes. + * @param time - Exact calendar fields of a real date, or an already-built plain date-time. + * @param timeZone - Already-canonical `UTC` or IANA zone. + * @returns The resolved epoch milliseconds, or `undefined` for a nonexistent wall-clock time. + * @throws RangeError when `timeZone` is not a known zone or the calendar value does not exist. + */ +export function resolveLocalInstant( + time: LocalCalendarTime | Temporal.PlainDateTime, + timeZone: string, +): number | undefined { + // A caller walking candidate dates already holds an instance, and `from` copies it. + const local = time instanceof Temporal.PlainDateTime + ? time + : Temporal.PlainDateTime.from(time, { overflow: 'reject' }) + const zoned = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' }) + return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : undefined +} diff --git a/packages/util/time/tests/duration.spec.ts b/packages/util/time/tests/duration.spec.ts new file mode 100644 index 0000000000..1430bf7805 --- /dev/null +++ b/packages/util/time/tests/duration.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { Duration } from '@deepseek-ai/dsh-util-time' + +/** Rebuild a span from its own parts, so a wrong split shows up as a wrong total. */ +const partSum = (parts: { + days: number + hours: number + minutes: number + seconds: number + milliseconds: number +}): number => + parts.days * 86_400_000 + parts.hours * 3_600_000 + + parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds + +/** Deterministic span source, so a failure is reproducible from the seed alone. */ +const spans = (count: number, seed: number): number[] => { + const out: number[] = [] + let state = seed + for (let i = 0; i < count; i += 1) { + state = (state * 1_103_515_245 + 12_345) % 2_147_483_648 + out.push((state / 2_147_483_648) * Number.MAX_SAFE_INTEGER) + } + return out +} + +describe('Duration construction', () => { + it('accepts a fractional millisecond count and keeps the fraction', () => { + expect(Duration.fromMilliseconds(1_500.25).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 500.25 }) + }) + + it('accepts the largest exactly representable count', () => { + const parts = Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER).decompose() + expect(parts).toEqual({ days: 104_249_991, hours: 8, minutes: 59, seconds: 0, milliseconds: 991 }) + expect(partSum(parts)).toBe(Number.MAX_SAFE_INTEGER) + }) + + it('refuses a count that is not a finite number', () => { + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + expect(() => Duration.fromMilliseconds(value)).toThrow(TypeError) + } + }) + + it('refuses a count above the exactly representable millisecond range', () => { + expect(() => Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER + 1)).toThrow(TypeError) + expect(() => Duration.fromMilliseconds(-Number.MAX_SAFE_INTEGER - 2)).toThrow(TypeError) + // The bound is what keeps the whole-part identity exact, so it is asserted, not assumed. + expect(partSum(Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER).decompose())) + .toBe(Number.MAX_SAFE_INTEGER) + }) + + it('is frozen, and overrides no implicit conversion', () => { + const span = Duration.fromMilliseconds(1) + expect(Object.isFrozen(span)).toBe(true) + expect(Reflect.set(span, 'span', 2)).toBe(false) + expect(span.decompose().milliseconds).toBe(1) + // No coercion hook is overridden, so no caller obtains the count by coercion. + expect(Object.hasOwn(Duration.prototype, 'valueOf')).toBe(false) + expect(Object.hasOwn(Duration.prototype, 'toString')).toBe(false) + expect(Object.hasOwn(Duration.prototype, Symbol.toPrimitive)).toBe(false) + }) +}) + +describe('Duration decompose', () => { + it('splits a span into floored whole parts and the fractional remainder', () => { + expect(Duration.fromMilliseconds(0).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }) + expect(Duration.fromMilliseconds(999).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 999 }) + expect(Duration.fromMilliseconds(1_000).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 0 }) + expect(Duration.fromMilliseconds(3_661_999).decompose()) + .toEqual({ days: 0, hours: 1, minutes: 1, seconds: 1, milliseconds: 999 }) + expect(Duration.fromMilliseconds(90_061_500).decompose()) + .toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500 }) + expect(Duration.fromMilliseconds(90_061_500.75).decompose()) + .toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500.75 }) + expect(Duration.fromMilliseconds(86_400_000).decompose()) + .toEqual({ days: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }) + }) + + it('answers frozen parts', () => { + expect(Object.isFrozen(Duration.fromMilliseconds(3_661_999).decompose())).toBe(true) + }) + + it('keeps the whole-part identity across the accepted range', () => { + for (let span = 0; span <= 120_000; span += 1) { + expect(partSum(Duration.fromMilliseconds(span).decompose())).toBe(span) + } + for (const span of spans(2_000, 20_260_924)) { + const parts = Duration.fromMilliseconds(span).decompose() + expect(partSum(parts)).toBe(span) + expect(parts.milliseconds).toBeGreaterThanOrEqual(0) + expect(parts.milliseconds).toBeLessThan(1_000) + expect(parts.hours).toBeLessThan(24) + expect(parts.minutes).toBeLessThan(60) + expect(parts.seconds).toBeLessThan(60) + } + }) + + it('refuses a negative span instead of inventing a sign policy', () => { + expect(() => Duration.fromMilliseconds(-1).decompose()).toThrow(RangeError) + expect(() => Duration.fromMilliseconds(-0.5).decompose()).toThrow(RangeError) + }) +}) diff --git a/packages/util/time/tests/instant.spec.ts b/packages/util/time/tests/instant.spec.ts new file mode 100644 index 0000000000..1be860b3fc --- /dev/null +++ b/packages/util/time/tests/instant.spec.ts @@ -0,0 +1,265 @@ +import { Temporal } from '@js-temporal/polyfill' +import { describe, expect, it } from 'vitest' +import { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + isFourDigitYearInstant, + parseLocalCalendar, + parseOffsetInstant, + resolveLocalInstant, +} from '@deepseek-ai/dsh-util-time' + +describe('parseLocalCalendar', () => { + it('reads exact calendar fields and normalizes an absent fraction to zero', () => { + expect(parseLocalCalendar('2026-03-08', '02:30:00')).toEqual({ + kind: 'accepted', + time: { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0, millisecond: 0 }, + }) + }) + + it('pads a one- or two-digit fraction to milliseconds', () => { + expect(parseLocalCalendar('2026-03-08', '02:30:00.5')).toEqual({ + kind: 'accepted', + time: { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0, millisecond: 500 }, + }) + expect(parseLocalCalendar('2026-03-08', '02:30:00.25')).toEqual({ + kind: 'accepted', + time: { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0, millisecond: 250 }, + }) + }) + + it('distinguishes malformed text from an out-of-range field', () => { + expect(parseLocalCalendar('2026-3-08', '02:30:00').kind).toBe('rejected') + expect(parseLocalCalendar('2026-3-08', '02:30:00')).toEqual({ kind: 'rejected', reason: 'malformed' }) + expect(parseLocalCalendar('2026-03-08', '2:30:00')).toEqual({ kind: 'rejected', reason: 'malformed' }) + expect(parseLocalCalendar('2026-03-08', '02:30:00.1234')).toEqual({ kind: 'rejected', reason: 'malformed' }) + expect(parseLocalCalendar('0000-03-08', '02:30:00')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + expect(parseLocalCalendar('2026-03-08', '24:00:00')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + expect(parseLocalCalendar('2026-03-08', '02:60:00')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + expect(parseLocalCalendar('2026-03-08', '02:30:60')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + }) + + it('rejects a date the calendar does not have', () => { + expect(parseLocalCalendar('2026-02-30', '02:30:00')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + expect(parseLocalCalendar('2025-02-29', '02:30:00')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + expect(parseLocalCalendar('2024-02-29', '02:30:00').kind).toBe('accepted') + }) +}) + +describe('parseOffsetInstant', () => { + it('reads a Z instant as its UTC epoch', () => { + expect(parseOffsetInstant('2026-03-08T02:30:00Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.000Z'), + }) + }) + + it('subtracts a numeric offset from the written wall-clock value', () => { + const wallClock = Date.parse('2026-03-08T02:30:00.000Z') + expect(parseOffsetInstant('2026-03-08T02:30:00+08:00')).toEqual({ + kind: 'accepted', + epoch: wallClock - 8 * 3_600_000, + }) + expect(parseOffsetInstant('2026-03-08T02:30:00-05:30')).toEqual({ + kind: 'accepted', + epoch: wallClock + 5.5 * 3_600_000, + }) + }) + + it('normalizes an optional one-to-three digit fraction to milliseconds', () => { + expect(parseOffsetInstant('2026-03-08T02:30:00.5Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.500Z'), + }) + expect(parseOffsetInstant('2026-03-08T02:30:00.25Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.250Z'), + }) + }) + + it('rejects text that is not the accepted RFC 3339 profile', () => { + for (const value of [ + '2026-03-08T02:30:00', + '2026-03-08 02:30:00Z', + '2026-03-08T02:30Z', + '2026-03-08T02:30:00.1234Z', + '2026-3-08T02:30:00Z', + ]) { + expect(parseOffsetInstant(value)).toEqual({ kind: 'rejected', reason: 'not_rfc3339' }) + } + }) + + it('rejects a clock field, a zero year, and a non-existent calendar date', () => { + expect(parseOffsetInstant('0000-03-08T02:30:00Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-03-08T24:30:00Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-03-08T02:60:00Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-03-08T02:30:60Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-02-30T02:30:00Z')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + }) + + it('rejects an unusable numeric offset', () => { + expect(parseOffsetInstant('2026-03-08T02:30:00+24:00')).toEqual({ + kind: 'rejected', + reason: 'invalid_numeric_offset', + }) + expect(parseOffsetInstant('2026-03-08T02:30:00+08:60')).toEqual({ + kind: 'rejected', + reason: 'invalid_numeric_offset', + }) + expect(parseOffsetInstant('2026-03-08T02:30:00-00:00')).toEqual({ + kind: 'rejected', + reason: 'invalid_numeric_offset', + }) + expect(parseOffsetInstant('2026-03-08T02:30:00+00:00')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.000Z'), + }) + }) +}) + +describe('fourDigitYearEpoch', () => { + it('accepts the canonical profile and answers its epoch', () => { + expect(fourDigitYearEpoch('2026-03-08T02:30:00.000Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.000Z'), + }) + expect(fourDigitYearEpoch('0001-01-01T00:00:00.000Z')).toEqual({ + kind: 'accepted', + epoch: MIN_FOUR_DIGIT_YEAR_MS, + }) + expect(fourDigitYearEpoch('9999-12-31T23:59:59.999Z')).toEqual({ + kind: 'accepted', + epoch: MAX_FOUR_DIGIT_YEAR_MS, + }) + }) + + it('rejects everything outside the canonical profile', () => { + for (const value of [ + '2026-03-08T02:30:00Z', + '2026-03-08T02:30:00.000+00:00', + '2026-03-08T02:30:00.00Z', + '0000-01-01T00:00:00.000Z', + '2026-3-08T02:30:00.000Z', + ]) { + expect(fourDigitYearEpoch(value)).toEqual({ kind: 'rejected', reason: 'not_rfc3339' }) + } + }) + + it('rejects a profile-shaped text whose date does not exist', () => { + expect(fourDigitYearEpoch('2026-02-30T02:30:00.000Z')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + }) +}) + +describe('isFourDigitYearInstant', () => { + it('answers the range including both bounds and excludes everything else', () => { + expect(isFourDigitYearInstant(MIN_FOUR_DIGIT_YEAR_MS)).toBe(true) + expect(isFourDigitYearInstant(MAX_FOUR_DIGIT_YEAR_MS)).toBe(true) + expect(isFourDigitYearInstant(Date.parse('2026-03-08T02:30:00.000Z'))).toBe(true) + for (const epoch of [MIN_FOUR_DIGIT_YEAR_MS - 1, MAX_FOUR_DIGIT_YEAR_MS + 1, 0.5, Number.NaN]) { + expect(isFourDigitYearInstant(epoch)).toBe(false) + } + }) +}) + +describe('formatFourDigitYearInstant', () => { + it('formats both inclusive bounds in the canonical profile', () => { + expect(formatFourDigitYearInstant(MIN_FOUR_DIGIT_YEAR_MS)).toBe('0001-01-01T00:00:00.000Z') + expect(formatFourDigitYearInstant(MAX_FOUR_DIGIT_YEAR_MS)).toBe('9999-12-31T23:59:59.999Z') + }) + + it('refuses a non-integral or out-of-range epoch', () => { + for (const epoch of [MIN_FOUR_DIGIT_YEAR_MS - 1, MAX_FOUR_DIGIT_YEAR_MS + 1, 0.5, Number.NaN]) { + expect(formatFourDigitYearInstant(epoch)).toBeUndefined() + } + }) +}) + +describe('canonicalizeTimeZone', () => { + it('answers the runtime-canonical name', () => { + expect(canonicalizeTimeZone('UTC')).toEqual({ kind: 'accepted', timeZone: 'UTC' }) + expect(canonicalizeTimeZone('US/Eastern')).toEqual({ kind: 'accepted', timeZone: 'America/New_York' }) + expect(canonicalizeTimeZone('America/New_York')).toEqual({ + kind: 'accepted', + timeZone: 'America/New_York', + }) + }) + + it('reports why a name is refused', () => { + expect(canonicalizeTimeZone('')).toEqual({ kind: 'rejected', reason: 'blank' }) + expect(canonicalizeTimeZone(' UTC')).toEqual({ kind: 'rejected', reason: 'padded' }) + expect(canonicalizeTimeZone('UTC ')).toEqual({ kind: 'rejected', reason: 'padded' }) + expect(canonicalizeTimeZone('Asia')).toEqual({ kind: 'rejected', reason: 'not_area_location' }) + expect(canonicalizeTimeZone('GMT+8')).toEqual({ kind: 'rejected', reason: 'not_area_location' }) + expect(canonicalizeTimeZone('utc')).toEqual({ kind: 'rejected', reason: 'not_area_location' }) + const unsupported = canonicalizeTimeZone('Not/A_Real_Zone') + if (unsupported.kind !== 'rejected') throw new Error('expected a rejection') + expect(unsupported.reason).toBe('unsupported') + // The platform refusal is the caller's only evidence of why a well-formed name failed, + // so the rejection carries it instead of hiding it. + expect(unsupported.cause).toBeInstanceOf(RangeError) + expect(canonicalizeTimeZone('Asia')).not.toHaveProperty('cause') + }) +}) + +describe('resolveLocalInstant', () => { + const local = (hour: number, minute: number) => ({ + year: 2026, month: 3, day: 8, hour, minute, second: 0, millisecond: 0, + }) + + it('resolves a wall-clock value that exists exactly once', () => { + expect(resolveLocalInstant(local(12, 0), 'America/New_York')).toBe( + Date.parse('2026-03-08T16:00:00.000Z'), + ) + }) + + it('answers undefined for a forward-transition gap', () => { + // 2026-03-08T02:30 local does not exist in America/New_York. + expect(resolveLocalInstant(local(2, 30), 'America/New_York')).toBeUndefined() + }) + + it('selects the earlier instant of a backward-transition overlap', () => { + // 2026-11-01T01:30 local occurs twice in America/New_York. + const overlap = { ...local(1, 30), month: 11, day: 1 } + expect(resolveLocalInstant(overlap, 'America/New_York')).toBe( + Date.parse('2026-11-01T05:30:00.000Z'), + ) + }) + + it('resolves UTC without a transition', () => { + expect(resolveLocalInstant(local(2, 30), 'UTC')).toBe(Date.parse('2026-03-08T02:30:00.000Z')) + }) + + it('accepts an already-built plain date-time, so a caller that holds one needs no copy', () => { + expect(resolveLocalInstant(Temporal.PlainDateTime.from('2026-03-08T12:00:00'), 'America/New_York')) + .toBe(Date.parse('2026-03-08T16:00:00.000Z')) + expect(resolveLocalInstant(Temporal.PlainDateTime.from('2026-03-08T02:30:00'), 'America/New_York')) + .toBeUndefined() + }) +}) diff --git a/packages/web/web-deepseek-api/package.json b/packages/web/web-deepseek-api/package.json index 2e94db4578..5aa5410945 100644 --- a/packages/web/web-deepseek-api/package.json +++ b/packages/web/web-deepseek-api/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*" diff --git a/packages/web/web-deepseek-api/tests/search.spec.ts b/packages/web/web-deepseek-api/tests/search.spec.ts index 942d252457..cb6c72e868 100644 --- a/packages/web/web-deepseek-api/tests/search.spec.ts +++ b/packages/web/web-deepseek-api/tests/search.spec.ts @@ -6,6 +6,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import WebRuntime from '@deepseek-ai/dsh-web' import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { DeepSeekSearchApiProvider, DEEPSEEK_WEB_API_BASE_URL, @@ -158,6 +159,7 @@ describe('Search API triggering question', () => { vi.stubGlobal('fetch', fetchMock) const ctx = new Context() try { + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) await ctx.plugin(webApiPlugin, { apiKey: 'ds-key', ...config }) @@ -239,6 +241,7 @@ describe('Search API account route', () => { const ctx = new Context() try { await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) ctx.provide('deepseekAccount', { diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index a88f9024e6..5471877f72 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' @@ -704,6 +705,7 @@ describe('web-search-deepseek account route selection', () => { const ctx = new Context() try { await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) ctx.provide('deepseekAccount', { diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 7375e52194..229c1e6eaf 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -449,6 +449,7 @@ describe('dsh-tool-workflow', () => { onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) @@ -659,10 +660,10 @@ describe('dsh-tool-workflow', () => { onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) - await ctx.plugin(SessionProjectionRegistry) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ diff --git a/packages/workflow/workflow-ptc/tests/host.spec.ts b/packages/workflow/workflow-ptc/tests/host.spec.ts index 4a866175a5..5254a25ceb 100644 --- a/packages/workflow/workflow-ptc/tests/host.spec.ts +++ b/packages/workflow/workflow-ptc/tests/host.spec.ts @@ -34,8 +34,8 @@ async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Pro const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjections) + await ctx.plugin(AgentRegistry) await ctx.plugin(SandboxPolicy, { mode: 'read-only' }) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) diff --git a/packages/workflow/workflow-ptc/tests/setup.ts b/packages/workflow/workflow-ptc/tests/setup.ts index 0a63dad5f8..3feb65d171 100644 --- a/packages/workflow/workflow-ptc/tests/setup.ts +++ b/packages/workflow/workflow-ptc/tests/setup.ts @@ -37,8 +37,8 @@ export async function mountWorkflowRuntime( options: { cwd?: string; mode?: SandboxMode; runtimeConfig?: NodeRuntimeConfig } = {}, ): Promise { if (!ctx.get('sessions')) await ctx.plugin(SessionStore) - if (!ctx.get('agents')) await ctx.plugin(AgentRegistry) if (!ctx.get('sessionProjections')) await ctx.plugin(SessionProjections) + if (!ctx.get('agents')) await ctx.plugin(AgentRegistry) if (!ctx.get('fs')) await ctx.plugin(FileSystem) if (!ctx.get('subprocess')) await ctx.plugin(Subprocess) if (!ctx.get('sandbox')) await ctx.plugin(Sandbox) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 194d2c5e53..4325f3cd23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@deepseek-ai/dsh-cua': specifier: workspace:* version: link:packages/experimental/cua + '@deepseek-ai/dsh-cua-bundle': + specifier: workspace:* + version: link:packages/experimental/cua-bundle '@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp': specifier: workspace:* version: link:packages/experimental/browser-use-chrome-devtools-mcp @@ -87,6 +90,9 @@ importers: '@deepseek-ai/dsh-skill-office': specifier: workspace:* version: link:packages/skill/skill-office + '@deepseek-ai/dsh-subagent-dsh-sdk': + specifier: workspace:* + version: link:packages/subagent/subagent-dsh-sdk '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:packages/experimental/tool-ralph @@ -306,6 +312,9 @@ importers: '@deepseek-ai/dsh-cordis-client-runner': specifier: workspace:* version: link:../../packages/extensions/cordis-client-runner + '@deepseek-ai/dsh-cua-bundle': + specifier: workspace:* + version: link:../../packages/experimental/cua-bundle '@deepseek-ai/dsh-experimental-agent-team-profile': specifier: workspace:* version: link:../../packages/experimental/agent-team-profile @@ -2052,6 +2061,12 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:* version: link:../../util/atomic-write + '@deepseek-ai/dsh-plugin-detail': + specifier: workspace:* + version: link:../../util/plugin-detail + '@deepseek-ai/dsh-timeout': + specifier: workspace:* + version: link:../../util/timeout '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -2061,6 +2076,9 @@ importers: execa: specifier: ^10.0.1 version: 10.0.1 + semver: + specifier: ^7.8.5 + version: 7.8.5 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -2083,6 +2101,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-client-connection': + specifier: workspace:* + version: link:../../client/connection '@deepseek-ai/dsh-hmr': specifier: workspace:* version: link:../hmr @@ -2119,6 +2140,9 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../../interaction/user-approval + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -2128,9 +2152,18 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../app-boot + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:* + version: link:../../util/atomic-write + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../../util/brand '@deepseek-ai/dsh-package-manifest': specifier: workspace:* version: link:../../util/package-manifest + '@deepseek-ai/dsh-plugin-detail': + specifier: workspace:* + version: link:../../util/plugin-detail '@deepseek-ai/dsh-plugin-market-index': specifier: workspace:* version: link:../../util/plugin-market-index @@ -2143,6 +2176,12 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + execa: + specifier: ^10.0.1 + version: 10.0.1 + hosted-git-info: + specifier: ^9.0.3 + version: 9.0.3 minisearch: specifier: ^7.2.0 version: 7.2.0 @@ -2162,6 +2201,9 @@ importers: '@deepseek-ai/cosmokit': specifier: link:../../../vendor/cosmokit version: link:../../../vendor/cosmokit + '@deepseek-ai/dsh-client-connection': + specifier: workspace:* + version: link:../../client/connection '@deepseek-ai/dsh-config-editor': specifier: workspace:* version: link:../config-editor @@ -2174,6 +2216,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:* version: link:../../settings/settings + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt @@ -2183,6 +2228,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol + '@types/hosted-git-info': + specifier: ^3.0.5 + version: 3.0.5 '@types/semver': specifier: ^7.8.0 version: 7.8.0 @@ -2658,6 +2706,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:* + version: link:../../session/session-checkpoint-policy '@deepseek-ai/dsh-session-log-deepseek': specifier: workspace:* version: link:../../session/session-log-deepseek @@ -7088,6 +7139,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools + '@deepseek-ai/dsh-util-time': + specifier: workspace:* + version: link:../../util/time packages/context/tmux-context: dependencies: @@ -7124,6 +7178,13 @@ importers: version: link:../../core/system-prompt packages/core/agent: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../../util/brand + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -7173,6 +7234,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection packages/core/agent-default-model: dependencies: @@ -8510,12 +8574,12 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:* version: link:../../util/atomic-write - '@deepseek-ai/dsh-brand': + '@deepseek-ai/dsh-computer-use': specifier: workspace:* - version: link:../../util/brand - '@deepseek-ai/dsh-util-values': + version: link:../../computer-use/computer-use + '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* - version: link:../../util/values + version: link:../../typert/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -8544,6 +8608,15 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:* version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-agent-preset-registry': + specifier: workspace:* + version: link:../../preset/agent-preset-registry + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:* + version: link:../../api/gateway + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:* + version: link:../../api/remotes '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../../boot/app-boot @@ -8553,9 +8626,36 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:* version: link:../../attachment/attachment-local - '@deepseek-ai/dsh-computer-use': + '@deepseek-ai/dsh-brand': specifier: workspace:* - version: link:../../computer-use/computer-use + version: link:../../util/brand + '@deepseek-ai/dsh-client-locale': + specifier: workspace:* + version: link:../../client/locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:* + version: link:../../client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:* + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:* + version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:* + version: link:../../client/ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:* + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:* + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:* + version: link:../../client/ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:* + version: link:../../client/ui-slots '@deepseek-ai/dsh-config-editor': specifier: workspace:* version: link:../../boot/config-editor @@ -8571,6 +8671,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:* + version: link:../../test-support/loader-smoke '@deepseek-ai/dsh-ptc-runtime': specifier: workspace:* version: link:../../ptc-runtime/ptc-runtime @@ -8583,6 +8686,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:* version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-scope': + specifier: workspace:* + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -8607,13 +8713,51 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:* version: link:../../interaction/user-questions + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@deepseek-ai/dsh-working-directory': specifier: workspace:* version: link:../../session/working-directory + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) sharp: specifier: ^0.35.3 version: 0.35.5(@types/node@22.20.1) + packages/experimental/cua-bundle: + dependencies: + '@deepseek-ai/dsh-computer-use': + specifier: workspace:* + version: link:../../computer-use/computer-use + '@deepseek-ai/dsh-cua': + specifier: workspace:* + version: link:../cua + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:~ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader + js-yaml: + specifier: ^4.2.0 + version: 4.3.2 + optionalDependencies: + '@deepseek-ai/dsh-experimental-cua-macos': + specifier: workspace:* + version: link:../cua-macos + packages/experimental/cua-macos: dependencies: '@deepseek-ai/dsh-atomic-write': @@ -12120,6 +12264,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol + '@deepseek-ai/dsh-util-time': + specifier: workspace:* + version: link:../../util/time '@deepseek-ai/dsh-workspace': specifier: workspace:* version: link:../../workspace/workspace @@ -14668,9 +14815,6 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:* version: link:../../shell/pwsh-local - '@deepseek-ai/dsh-session-projection': - specifier: workspace:* - version: link:../../session/session-projection '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -14696,6 +14840,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection '@deepseek-ai/dsh-subprocess': specifier: workspace:* version: link:../../subprocess/subprocess @@ -15219,6 +15366,12 @@ importers: specifier: workspace:~ version: link:../../../vendor/cordis + packages/util/plugin-detail: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + packages/util/plugin-market-index: dependencies: '@deepseek-ai/dsh-package-manifest': @@ -15236,6 +15389,10 @@ importers: version: 7.8.0 packages/util/time: + dependencies: + '@js-temporal/polyfill': + specifier: 0.5.1 + version: 0.5.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -15363,6 +15520,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection '@deepseek-ai/dsh-web': specifier: workspace:* version: link:../web diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index a48b0431d8..87593796ff 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -12,8 +12,8 @@ en: 37884fe76e167a0e zh: 4b6e50768c4bb945 /python-contributor-workflows/run-against-node-source: - en: 7430d9e6b3d02ddc - zh: 9044e6be91d0ad01 + en: 940c941d189a8248 + zh: 321f5183383d4a12 /python-contributor-workflows/build-distributions: en: 4aa941562dba5482 zh: 50543a92b3d0cf04 diff --git a/python/development.md b/python/development.md index b1e0f62b40..ed12f6994d 100644 --- a/python/development.md +++ b/python/development.md @@ -66,6 +66,14 @@ Repository contributors can select either development route; both execute the no `python/sdk/tests/manual_sdk_agent_smoke.py` uses the internal `_launch_args` test adapter to exercise the unbuilt TypeScript CLI under tsx. Arbitrary argv replacement is intentionally absent from the public SDK. +The manual [shutdown example](sdk/tests/manual_sdk_shutdown_smoke.py) uses ordinary SDK calls: run one short real-model turn, close immediately with the default 1-second budget, then open another runtime and restore the same session. Install the SDK/runtime and set `DEEPSEEK_API_KEY` before running: + +```sh +python python/sdk/tests/manual_sdk_shutdown_smoke.py +``` + +The example prints the close duration and history files, retains its temporary directory, and lets restoration failures raise normally. Recovery uses a separate 30-second shutdown budget. Reproduction depends on the host and first-turn timing; successful restoration does not establish a shutdown latency bound. This manual example is not collected by pytest. + ## Build distributions The root `package.json` version is authoritative for both Python distributions. The staging script injects that version into both wheels and pins the SDK to the same `deepseek-harness-runtime-bin` version. diff --git a/python/development.zh.md b/python/development.zh.md index dae84b9eb1..d37cba5cda 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -66,6 +66,14 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/test-dsh-home") as harness: `python/sdk/tests/manual_sdk_agent_smoke.py` 使用内部 `_launch_args` 测试适配器,通过 tsx 验证未构建的 TypeScript CLI。公开 SDK 刻意不提供任意 argv 替换。 +手动[关闭示例](sdk/tests/manual_sdk_shutdown_smoke.py)只使用普通 SDK 调用:运行一轮真实模型短对话,以默认 1 秒预算立即关闭,再启动另一个运行时恢复同一会话。安装 SDK/运行时并设置 `DEEPSEEK_API_KEY` 后运行: + +```sh +python python/sdk/tests/manual_sdk_shutdown_smoke.py +``` + +示例打印关闭耗时和历史文件列表,保留临时目录,并让恢复失败正常抛出异常。恢复进程使用独立的 30 秒关闭预算。能否复现取决于主机和首轮耗时,恢复成功不能确定关闭耗时上限。pytest 不收集此手动示例。 + ## 构建分发包 根目录 `package.json` 的版本是两个 Python 分发包的权威版本。暂存脚本会将该版本注入两个 wheel 包,并将 SDK 固定到同版本的 `deepseek-harness-runtime-bin`。 diff --git a/python/sdk/tests/manual_sdk_shutdown_smoke.py b/python/sdk/tests/manual_sdk_shutdown_smoke.py new file mode 100644 index 0000000000..2b3044532a --- /dev/null +++ b/python/sdk/tests/manual_sdk_shutdown_smoke.py @@ -0,0 +1,36 @@ +"""Run a short SDK session, close immediately, and try restoring its history. + +Requires an installed SDK/runtime and DEEPSEEK_API_KEY. Run this file directly. +The temporary Harness home is retained for inspection. +""" + +from pathlib import Path +from tempfile import mkdtemp +from time import monotonic + +from deepseek_harness import DeepSeekHarness, DeepSeekHarnessConfig + +root = Path(mkdtemp(prefix="dsh-shutdown-")) +config = DeepSeekHarnessConfig( + profile="sdk-minimal", + dsh_home=str(root / "home"), + cwd=str(root), + shutdown_timeout_seconds=1.0, +) +print("Test directory:", root) + +with DeepSeekHarness(config) as harness: + result = harness.run("只回复 OK,不要调用工具。") + started = monotonic() + +print(f"close() took {monotonic() - started:.3f}s") +print("Reply:", result.final_response) +if result.finish_reason != "completed": + raise RuntimeError(f"Model turn failed ({result.finish_reason}); check credentials and the session log.") +print("History files:", list((root / "home" / "sessions").rglob("*.jsonl"))) + +# Recovery gets a separate budget so its own shutdown does not repeat the test. +config.shutdown_timeout_seconds = 30.0 +with DeepSeekHarness(config) as harness: + harness.resume_session(result.session_id) + print("History restored successfully:", result.session_id) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c02b46ed3b..108a345794 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -181,7 +181,7 @@ const packageFileExtras: Readonly> = { // The embedded OAR implementation retains its upstream license. '@deepseek-ai/dsh-experimental-subagent-oar': ['LICENSE-OAR'], // CUA publishes its client API and attributed native source for local macOS builds. - '@deepseek-ai/dsh-cua': ['lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/protocol.js'], + '@deepseek-ai/dsh-cua': ['lib/remote-types.js', 'lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/protocol.js'], '@deepseek-ai/dsh-experimental-cua-macos': ['native/bin', 'native/Package.swift', 'native/Sources', 'native/tests/swift', 'native/tests/test_swift.py', 'native/LICENSE.cua-driver', 'native/upstream.json', 'native/README.md', 'native/README.zh.md', 'native/protocol.schema.json'], '@deepseek-ai/dsh-webhook-github': ['examples/github-review/cordis.yml', 'examples/github-review/github-ready-review-rule.mjs'], // Owned Worker bundles import this public bootstrap before their business entry. diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 1040b5f18b..e30512816e 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -671,15 +671,20 @@ describe('CI workflow', () => { expect(uploadIndex).toBeGreaterThan(benchmarkIndex) }) - it('generates metadata before the Wine Host TypeScript compile and retains its heap budget', () => { + it('splits the Wine Host TypeScript compile and retains its heap budget', () => { const wineGates = readFileSync(resolve(root, 'scripts/wine-windows-gates.sh'), 'utf8') - expect(wineGates).toContain( - 'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false', + const references = wineGates.indexOf( + 'wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host', + ) + const aggregate = wineGates.indexOf( + 'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false', ) const metadata = wineGates.indexOf('wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts') expect(metadata).toBeGreaterThan(-1) - expect(metadata).toBeLessThan(wineGates.indexOf('wine_node "$scratch/logs/host-tsc.log"')) + expect(references).toBeGreaterThan(metadata) + expect(aggregate).toBeGreaterThan(references) + expect(wineGates).not.toContain('-b tsconfig.host.json') }) it('cancels superseded master runs without changing the post-merge job inventory', () => { diff --git a/scripts/coverage-fork-diagnostics.spec.ts b/scripts/coverage-fork-diagnostics.spec.ts index be555a3003..4450245255 100644 --- a/scripts/coverage-fork-diagnostics.spec.ts +++ b/scripts/coverage-fork-diagnostics.spec.ts @@ -159,7 +159,7 @@ function workerExitReport(output: string, pool: string): string | undefined { } describe('coverage fork configuration entry', () => { - it('preserves the real Vitest failure and adds metadata before its error report', async ({ signal }) => { + it('preserves the real Vitest failure and attributes the same exit Vitest reports', async ({ signal }) => { const [stock, diagnostic] = await Promise.all([ configuredPool('stock', 'exit', signal), configuredPool('diagnostic', 'exit', signal), @@ -196,8 +196,13 @@ describe('coverage fork configuration entry', () => { }) expect(record.files.map(file => file.replaceAll('\\', '/'))) .toEqual([resolve('scripts/fixtures/coverage-fork.fixture.ts').replaceAll('\\', '/')]) - expect(output.indexOf('coverage-worker-exit:')) - .toBeLessThan(output.indexOf(workerExitReport(output, 'coverage-forks')!)) + const report = workerExitReport(output, 'coverage-forks')! + // A worker error is logged when the channel closes under a send, which precedes the exit the + // record describes. Vitest then prints no "Worker exited unexpectedly" line, so only that + // line states an order the record can hold. + if (report === 'Worker exited unexpectedly') { + expect(output.indexOf('coverage-worker-exit:')).toBeLessThan(output.indexOf(report)) + } }) it('keeps a successful configured run silent and successful', async ({ signal }) => { diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 215048297e..dc6d976d34 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -55,7 +55,6 @@ const KIND_TEMPLATES: Readonly> = { * library; the check re-derives the entry shape so a stale entry fails loud. */ const PACKAGE_LIBRARIES: Readonly> = { - 'packages/experimental/cua': 'Shared CUA objects and installer consumed by platform providers; no plugin entry.', 'packages/experimental/browser-use-runtime': 'Provider-owned browser resource management and MCP integration helpers; no plugin entry.', 'packages/boot/app-boot': 'Boot library the app bins import; plain helper exports.', 'packages/boot/cmdline': 'Command-line library the app bins import; plain module exports.', @@ -100,6 +99,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/native-command': 'Host-side subprocess runner utility.', 'packages/util/output-retention': 'Zero-dependency retention utility.', 'packages/util/package-manifest': 'Shared package manifest declarations with type-only exports.', + 'packages/util/plugin-detail': 'Plugin detail document library.', 'packages/util/plugin-market-index': 'Plugin market index validation library.', 'packages/util/time': 'Zero-dependency time-zone canonicalization utility.', 'packages/util/timeout': 'Zero-dependency timeout utility.', diff --git a/scripts/experimental-package-policy.ts b/scripts/experimental-package-policy.ts index 466acaed2a..44fbd52ebe 100644 --- a/scripts/experimental-package-policy.ts +++ b/scripts/experimental-package-policy.ts @@ -1,8 +1,9 @@ /** Experimental package identities, publication policy, and retained npm names. */ -/** Existing npm names retained by capabilities now housed under experimental/. */ +/** Explicit npm identities retained across experimental classification and package-role moves. */ export const EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS: Readonly> = { 'packages/experimental/cua': '@deepseek-ai/dsh-cua', + 'packages/experimental/cua-bundle': '@deepseek-ai/dsh-cua-bundle', 'packages/experimental/tool-session-query': '@deepseek-ai/dsh-tool-session-query', 'packages/experimental/session-title-all-prompts-llm': '@deepseek-ai/dsh-session-title-all-prompts-llm', 'packages/experimental/tool-terminal': '@deepseek-ai/dsh-tool-terminal', diff --git a/scripts/gen-config-catalog.spec.ts b/scripts/gen-config-catalog.spec.ts index 7248df7b98..4aed5f9782 100644 --- a/scripts/gen-config-catalog.spec.ts +++ b/scripts/gen-config-catalog.spec.ts @@ -97,6 +97,31 @@ export function apply(ctx: unknown, config: Config): void {} expect(new Set(provider?.schemaKeys)).toEqual(new Set(['mode', 'headless', 'endpoint'])) }) + it.each([false, true])('checks a class Config imported from a sibling module (invalid field: %s)', (invalid) => { + const { root, write } = fixture() + write('packages/test/provider/src/config.ts', ` +import Schema from '@deepseek-ai/schemastery' +export interface Config { + /** Maximum result count. */ + limit: number +} +export const Config = Schema.object({ ${invalid ? 'hidden' : 'limit'}: Schema.number() }) +`) + write('packages/test/provider/src/index.ts', ` +import { Config } from './config.ts' +export default class Provider { + static Config = Config + constructor(ctx: unknown, config: Config) {} +} +`) + if (invalid) { + expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden' but config type 'Config' declares no such member") + } else { + const provider = collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider') + expect(provider?.schemaKeys).toEqual(['limit']) + } + }) + it('rejects type-only imports used as runtime schemas', () => { const { root, write } = fixture() write('packages/test/provider/src/index.ts', ` diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 30b19e46ac..f5791d537d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -80,6 +80,7 @@ export const SERVICE_PAGE: Record = { ptcRuntime: 'ptc-runtime.md', browserUse: 'browser-use.md', computerUse: 'computer-use.md', + cuaAccess: 'computer-use.md', commands: 'commands.md', compaction: 'compaction.md', cordisInspect: 'extensions.md', @@ -311,6 +312,9 @@ export const LINK_MAP: Readonly> = { MarketQuery: 'boot.md', MarketSearchResult: 'boot.md', MarketSort: 'boot.md', + PluginDetail: 'boot.md', + BundleDetail: 'boot.md', + NpmFileSource: 'boot.md', ChangeResult: 'boot.md', InstallBundleOptions: 'boot.md', PluginEntryId: 'boot.md', @@ -321,10 +325,21 @@ export const LINK_MAP: Readonly> = { PluginChange: 'boot.md', PluginInstallLogChunk: 'boot.md', PluginInstallProgress: 'boot.md', + PluginUpdateProgress: 'boot.md', + PluginUpdateResult: 'boot.md', + PluginVersionList: 'boot.md', + ListVersionsOptions: 'boot.md', + UpdateBundleOptions: 'boot.md', PluginRegistries: 'boot.md', InspectOptions: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', + CuaApplicationId: 'computer-use.md', + CuaApplicationGrant: 'computer-use.md', + CuaPermissionProcess: 'computer-use.md', + CuaApplicationState: 'computer-use.md', + CuaSystemPermission: 'computer-use.md', + CuaSystemPermissionState: 'computer-use.md', RenderedDocumentBytes: 'office-to-pdf.md', OfficeToPdfRequest: 'office-to-pdf.md', OfficeToPdfResult: 'office-to-pdf.md', @@ -341,6 +356,7 @@ export const LINK_MAP: Readonly> = { AdmissionDecision: 'core.md', DeliveryMode: 'core.md', DeliveryRequest: 'core.md', + TurnBoundaryProjection: 'core.md', AgentStatus: 'core.md', AssistantStreamFrame: 'core.md', ContentBlock: 'llm-streaming.md', @@ -722,6 +738,7 @@ export const LINK_MAP: Readonly> = { ModDefinition: 'claude-code-mods.md', SurfaceSnapshot: 'claude-code-mods.md', TokenMeasurement: 'token-meter.md', + ContextPressureProjection: 'token-meter.md', PtcDispatchLog: 'tools.md', PostToolDecision: 'tools.md', PreToolDecision: 'tools.md', @@ -826,8 +843,8 @@ export const LINK_MAP: Readonly> = { WorkflowRunInfo: 'workflow.md', WorkflowStartRequest: 'workflow.md', ProjectionDefinition: 'session-projection.md', + ProjectionHandle: 'session-projection.md', SessionProjectionMap: 'session-projection.md', - SessionProjectionStateMap: 'session-projection.md', ProjectionKeySelection: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', ProjectionSnapshot: 'session-projection.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index de1b1ecce1..299eb8b04d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -223,6 +223,11 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'], note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.', }, + { + key: 'cuaAccess', pkg: 'cua', title: 'Computer Use permission controls', + mode: 'core', consumers: ['cua'], + note: 'The native provider owns application consent and cancellation; the optional Client page queries and changes those decisions through its Remote.', + }, { key: 'officeToPdf', pkg: 'office-to-pdf', title: 'Office to PDF conversion', mode: 'core', consumers: ['client-ui-sidebar-documentpreview'], diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8be42a24dd..8eb29fc8e7 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -271,9 +271,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-plan-mode', dir: 'plan-mode', source: 'packages/plan/plan-mode/src/index.ts', - requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userQuestions (execution time, opportunistic)'], + requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.agents', 'ctx.userQuestions (execution time, opportunistic)'], writes: ['tool/call', 'plan/mode inactive on an approved review', 'tool/result'], async mount(ctx) { + await ctx.plugin(AgentRegistry) await ctx.plugin(PlanModeController, { section: 'Tool catalog schema harvest.' }) }, note: @@ -301,9 +302,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-present', dir: 'tool-present', source: 'packages/deliverables/tool-present/src/index.ts', - requires: ['ctx.workingDirectory', 'ctx.tools', 'ctx.fs', 'ctx.sessionProjections'], + requires: ['ctx.workingDirectory', 'ctx.tools', 'ctx.fs', 'ctx.agents'], writes: ['tool/call', 'deliverables/presented after a successful final result', 'tool/result'], async mount(ctx) { + await ctx.plugin(AgentRegistry) await ctx.plugin(ToolPresent) }, note: 'Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.', @@ -507,10 +509,11 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-session-query', dir: 'tool-session-query', source: 'packages/experimental/tool-session-query/src/index.ts', - requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'a calling Agent for workspace authority'], + requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'ctx.agents', 'a calling Agent for workspace authority'], writes: ['tool/call', 'tool/result'], async mount(ctx) { await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:' }) await ctx.plugin(ToolSessionQuery) }, diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index ef15d1bbf7..95e498cdfc 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -51,6 +51,8 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [ * prominent heading in the pull request description. */ const SAFE_HOST_DEPENDENCY_EXPORTS = { + '@deepseek-ai/dsh-atomic-write': ['withFileLock'], + '@deepseek-ai/dsh-computer-use/brand': ['ComputerUseProviderName'], '@deepseek-ai/dsh-credentials': ['credentialKey'], '@deepseek-ai/dsh-deque': ['Deque'], '@deepseek-ai/dsh-llm': ['callConfigEquals'], @@ -61,6 +63,9 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { + // CUA checks UserQuestionError and preserves AttachmentError from image admission. + '@deepseek-ai/dsh-attachment': ['admitEncodedImages'], + '@deepseek-ai/dsh-user-questions': ['UserQuestionError'], // `instanceof` checks need the translator's own class instance. '@deepseek-ai/dsh-experimental-translator': ['TranslationError'], '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'], diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 4a6a12c0a0..5ead068208 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -66,6 +66,7 @@ describe('release families', () => { '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp', '@deepseek-ai/dsh-experimental-computer-use-cua-driver-native', '@deepseek-ai/dsh-experimental-cot-translation-bundle', + '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-cua-macos', '@deepseek-ai/dsh-cua', '@deepseek-ai/dsh-hook-protocol', diff --git a/scripts/release/verify-packed-install.spec.ts b/scripts/release/verify-packed-install.spec.ts index b40f21ae92..4eb45db00d 100644 --- a/scripts/release/verify-packed-install.spec.ts +++ b/scripts/release/verify-packed-install.spec.ts @@ -38,7 +38,8 @@ const path = require('node:path') const consumer = process.env.INIT_CWD || process.cwd() const cache = path.join(consumer, '.npm-cache', '_cacache') const files = fs.existsSync(cache) ? fs.readdirSync(cache, { recursive: true }).filter(name => fs.statSync(path.join(cache, name)).isFile()) : [] -fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files })) +fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files, + installed: ['matching', 'wrong-os', 'wrong-cpu'].filter(name => fs.existsSync(path.join(consumer, 'node_modules', name))) })) ` writeFileSync(join(source, 'probe.cjs'), probe + (outcome === 'install failure' ? '\nprocess.exit(37)\n' : '')) writeFileSync(join(source, 'lib/bin.js'), outcome === 'entry failure' ? 'process.exit(37)\n' : 'console.log("0.0.1")\n') @@ -51,6 +52,21 @@ fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files }) expect(archive.error).toBeUndefined() expect(archive.signal).toBeNull() expect(archive.status, archive.stderr).toBe(0) + for (const [name, platform] of [ + ['matching', { os: [process.platform], cpu: ['any'] }], + ['wrong-os', { os: [`!${process.platform}`] }], + ['wrong-cpu', { cpu: [process.arch === 'arm64' ? 'x64' : 'arm64'] }], + ] as const) { + const packageDirectory = join(directory, name, 'package') + mkdirSync(packageDirectory, { recursive: true }) + writeFileSync(join(packageDirectory, 'package.json'), JSON.stringify({ name, version: '0.0.1', ...platform })) + const packedPlatform = spawnSync('tar', ['-czf', join(packed, `${name}.tgz`), '-C', join(directory, name), 'package'], { + encoding: 'utf8', timeout: 30_000, + }) + expect(packedPlatform.error).toBeUndefined() + expect(packedPlatform.signal).toBeNull() + expect(packedPlatform.status, packedPlatform.stderr).toBe(0) + } const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^npm_config_/i.test(key))) Object.assign(environment, { npm_config_cache: ambient, @@ -68,7 +84,8 @@ fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files }) expect(result.error).toBeUndefined() expect(result.signal).toBeNull() expect(result.status, result.stdout + result.stderr).toBe(outcome === 'success' ? 0 : 1) - const observation = JSON.parse(readFileSync(evidence, 'utf8')) as { consumer: string; files: string[] } + const observation = JSON.parse(readFileSync(evidence, 'utf8')) as { consumer: string; files: string[]; installed: string[] } + expect(observation.installed).toEqual(['matching']) expect(observation.files.length, 'npm must write tarball content into the consumer-private cache').toBeGreaterThan(0) expect(observation.consumer).toContain('dsh-packed-dsh-') expect(existsSync(observation.consumer), 'the verifier finally must remove its consumer and cache').toBe(false) diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts index 82e209cf49..08ab4860c0 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' import { releaseFamily } from './families.ts' import { capture, isEntry } from './process.ts' -import { packedIdentity } from './tarball.ts' +import { packedManifest } from './tarball.ts' import { verifyInstalledProductIsolation } from './installed-product-isolation.ts' /** @@ -45,30 +45,46 @@ function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv { return environment } +function acceptsPlatform(value: unknown, current: string): boolean { + if (value === undefined) return true + if (!Array.isArray(value) || !value.every((tag): tag is string => typeof tag === 'string')) { + throw new Error('packed platform constraints must be arrays of strings') + } + let accepted = value.every(tag => tag.startsWith('!')) + for (const tag of value) { + if (tag === `!${current}`) return false + if (tag === current || (tag === 'any' && value.length === 1)) accepted = true + } + return accepted +} + /** - * Every packed tarball in the given directories, as `file:` dependency entries. + * Packed tarballs and their compatibility with the verification host, as `file:` dependency entries. * * The directories are read by their contents rather than a pack order file: a * directory here can hold tarballs packed only to satisfy a cross-sequence * dependency, which no release order describes. * @param directories - absolute directories holding packed tarballs. - * @returns Package name to tarball file URL, and the version each carries. + * @returns Package name to tarball URL, version, and host compatibility. */ -function packedDependencies(directories: readonly string[]): Map { - const dependencies = new Map() +function packedDependencies(directories: readonly string[]): Map { + const dependencies = new Map() for (const directory of directories) { const tarballs = readdirSync(directory).filter(name => name.endsWith('.tgz')).sort() if (tarballs.length === 0) throw new Error(`${directory} holds no packed tarball`) for (const filename of tarballs) { const tarball = join(directory, filename) - const { name, version } = packedIdentity(tarball) - dependencies.set(name, { url: pathToFileURL(tarball).href, version }) + const manifest = packedManifest(tarball) + const { name, version } = manifest + if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`) + const compatible = acceptsPlatform(manifest.os, process.platform) && acceptsPlatform(manifest.cpu, process.arch) + dependencies.set(name, { url: pathToFileURL(tarball).href, version, compatible }) } } return dependencies } -/** Install every tarball under `--from` and drive the `--family` entry. */ +/** Install platform-compatible tarballs under `--from` and drive the `--family` entry. */ function main(): void { const { values } = parseArgs({ options: { family: { type: 'string' }, from: { type: 'string', multiple: true } }, @@ -96,7 +112,11 @@ function main(): void { name: `dsh-packed-install-${family.id}`, version: '0.0.0', private: true, - dependencies: Object.fromEntries([...packed].map(([name, entryPacked]) => [name, entryPacked.url])), + dependencies: Object.fromEntries([...packed].filter(([, entryPacked]) => entryPacked.compatible) + .map(([name, entryPacked]) => [name, entryPacked.url])), + // Keep foreign-platform metadata local without turning those tarballs into required installations. + optionalDependencies: Object.fromEntries([...packed].filter(([, entryPacked]) => !entryPacked.compatible) + .map(([name, entryPacked]) => [name, entryPacked.url])), }, null, 2)}\n`) const environment = consumerEnvironment(consumerRoot) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5e7d419df3..a7c0978c25 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2181,6 +2181,11 @@ "symbol": "PresetSpec", "source": "packages/interaction/permission-presets/src/index.ts" }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "ProjectionHandle", + "source": "packages/session/session-projection/src/index.ts" + }, { "doc": "docs/subsystems/session-projection.md", "symbol": "ProjectionDefinition", @@ -2482,6 +2487,16 @@ "symbol": "PendingModule", "source": "packages/boot/hmr/src/index.ts" }, + { + "doc": "docs/subsystems/core.md", + "symbol": "TurnBoundaryProjection", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/subsystems/token-meter.md", + "symbol": "ContextPressureProjection", + "source": "packages/llm/token-meter/src/projection.ts" + }, { "doc": "docs/subsystems/session-projection.md", "symbol": "SessionProjectionSummaryExcluded", diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index ec0385af10..8c7209e86d 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -80,11 +80,14 @@ describe('workspace Bundle discovery and product dependency closures', () => { } const self = { file, name: '@deepseek-ai/dsh-subagent-example' } expect(bundlePluginDependencyErrors(manifestPath, manifest, [self])).toEqual([]) + expect(bundlePluginDependencyErrors(manifestPath, { + ...manifest, optionalDependencies: { '@deepseek-ai/dsh-platform-plugin': 'workspace:*' }, + }, [{ file, name: '@deepseek-ai/dsh-platform-plugin' }])).toEqual([]) expect(bundlePluginDependencyErrors(manifestPath, manifest, [ self, { file, name: '@deepseek-ai/dsh-missing-plugin' }, ])).toEqual([ - `${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies`, + `${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies or optionalDependencies`, ]) }) }) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 76e9bdba87..3de9914cdf 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -396,8 +396,8 @@ export function bundlePluginDependencyErrors( return missingPluginDependencies( // A Bundle may mount its own package (for example, its provider or runtime row). references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), - manifest.dependencies ?? {}, - `${manifestPath} dependencies`, + { ...manifest.dependencies, ...manifest.optionalDependencies }, + `${manifestPath} dependencies or optionalDependencies`, ) } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 421adf1569..be4dd9b16b 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -92,6 +92,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/util/chunked-list': { kind: 'none', reason: 'Immutable collection primitive; registers nothing model-facing.' }, 'packages/util/package-manifest': { kind: 'none', reason: 'Type declarations only; registers nothing model-facing.' }, 'packages/util/lazy-require': { kind: 'none', reason: 'Host module-loading primitive; registers nothing model-facing.' }, + 'packages/util/plugin-detail': { kind: 'none', reason: 'Pure document selection for plugin detail pages; registers nothing model-facing.' }, 'packages/util/plugin-market-index': { kind: 'indirect', reason: 'Pure index validation; the plugin_market tool owns the entries it returns to the model.' }, 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 785d6b1d2d..3e2c0ef291 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -243,12 +243,17 @@ grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-ga # ---- the two blocking surfaces, concurrently ------------------------------ # The build mirrors the root build:lib:host and build:lib:client scripts: -# generate metadata, compile and bundle the Host face, bundle apps/desktop from -# the Host lib/ outputs, then compile and bundle the Client face. +# generate metadata, compile the Host package projects, type-check the Host +# aggregate program, bundle the Host face, bundle apps/desktop from the Host +# lib/ outputs, then compile and bundle the Client face. The Host compile is two +# invocations because that is how the supported build splits it; a single +# `tsc -b` holds the reference graph and the aggregate program in one process +# and exceeds the heap the Host face is budgeted. # Both statuses are captured so one failure cannot hide the other's result. build_gate() { wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts || return $? - wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host || return $? + wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false || return $? wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? (cd apps/desktop && wine_node "$scratch/logs/desktop-tsdown.log" "../../$tsdown_js") || return $? wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? diff --git a/snapshots/sdk/minimal-image-compaction/checkpoint-observer.mjs b/snapshots/sdk/minimal-image-compaction/checkpoint-observer.mjs new file mode 100644 index 0000000000..4cba283cf2 --- /dev/null +++ b/snapshots/sdk/minimal-image-compaction/checkpoint-observer.mjs @@ -0,0 +1,26 @@ +/** Require a live-session checkpoint before the recorded minimal-profile model requests. */ +export const name = 'minimal-checkpoint-observer' +export const inject = ['llm', 'sessions', 'sessionPersistence'] + +/** + * Observe checkpoints without flushing or altering the recorded model transcript. + * @param ctx - Plugin context owning the checkpoint and model listeners. + */ +export function apply(ctx) { + const checkpointed = new WeakSet() + ctx.on('session/flush', session => { checkpointed.add(session) }) + ctx.on('llm/stream', async function* (options, next) { + const session = options.sessionId === undefined ? undefined : ctx.sessions.get(options.sessionId) + if (session !== undefined) { + if (!checkpointed.has(session)) throw new Error('sdk-minimal dispatched without a session checkpoint') + const handle = await ctx.sessionPersistence.open(session.id, 'read') + try { + const { events } = await handle.read() + if (events.length === 0) throw new Error('sdk-minimal dispatched before its history was persisted') + } finally { + await handle.close() + } + } + yield* next() + }) +} diff --git a/snapshots/sdk/minimal-image-compaction/cordis.yml b/snapshots/sdk/minimal-image-compaction/cordis.yml index 8e0759adae..388cc9c158 100644 --- a/snapshots/sdk/minimal-image-compaction/cordis.yml +++ b/snapshots/sdk/minimal-image-compaction/cordis.yml @@ -2,3 +2,7 @@ - id: session-log-deepseek config: enabled: false + +- insert: + - id: minimal-checkpoint-observer + name: './checkpoint-observer.mjs' diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 233e4915e5..5b30ddf9d4 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -952,6 +952,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const actualContext = contextOf(ordered, cwd) if (scenario.name === 'minimal-image-compaction') { const events = results.flatMap(result => result.events) + const failures = events.filter(event => event.type === 'turn/end' && event.data.reason.kind === 'error') + expect(failures, 'minimal profile completes its checkpointed turn').toEqual([]) const image = events.find(event => event.type === 'tool/result') expect(image).toMatchObject({ data: { message: { toolCallId: 'minimal-image-read', diff --git a/snapshots/session/cua-native/consent-fixture.mjs b/snapshots/session/cua-native/consent-fixture.mjs deleted file mode 100644 index e0b0fe035e..0000000000 --- a/snapshots/session/cua-native/consent-fixture.mjs +++ /dev/null @@ -1,14 +0,0 @@ -/** Scripted human consent for the native application replay. */ -import { join } from 'node:path' -import { startFixtureHost } from './fixture-host.mjs' -export const name = 'cua-consent-fixture' -export const inject = ['userQuestions'] - -/** Answer through the existing human-question service while native dispatch stays real. */ -export async function apply(ctx) { - const close = await startFixtureHost(join(process.cwd(), 'native-helper.mjs')) - ctx.effect(() => close) - ctx.on('user-questions/request', async request => ({ answers: request.questions.map(question => ({ - id: question.id, selected: ['Allow for this task'], - })) })) -} diff --git a/snapshots/session/cua-native/cordis.snapshot.yml b/snapshots/session/cua-native/cordis.snapshot.yml index f6708af93d..958f41ca53 100644 --- a/snapshots/session/cua-native/cordis.snapshot.yml +++ b/snapshots/session/cua-native/cordis.snapshot.yml @@ -50,11 +50,13 @@ - id: cua-native name: '@deepseek-ai/dsh-experimental-cua-macos' config: + agentPreset: false helperPath: !!js process.cwd() + '/native-helper.mjs' + allowedApps: [{"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "name": "Fixture", "executablePath": "/fixture/app"}] - id: user-questions name: '@deepseek-ai/dsh-user-questions' - insert: - - id: cua-consent-fixture - name: './consent-fixture.mjs' + - id: cua-permission-fixture + name: './permission-fixture.mjs' diff --git a/snapshots/session/cua-native/cordis.yml b/snapshots/session/cua-native/cordis.yml index b60d7872d4..c25eadc41d 100644 --- a/snapshots/session/cua-native/cordis.yml +++ b/snapshots/session/cua-native/cordis.yml @@ -32,11 +32,13 @@ - id: cua-native name: '@deepseek-ai/dsh-experimental-cua-macos' config: + agentPreset: false helperPath: !!js process.cwd() + '/native-helper.mjs' + allowedApps: [{"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "name": "Fixture", "executablePath": "/fixture/app"}] - id: user-questions name: '@deepseek-ai/dsh-user-questions' - insert: - - id: cua-consent-fixture - name: './consent-fixture.mjs' + - id: cua-permission-fixture + name: './permission-fixture.mjs' diff --git a/snapshots/session/cua-native/permission-fixture.mjs b/snapshots/session/cua-native/permission-fixture.mjs new file mode 100644 index 0000000000..e1580ceedc --- /dev/null +++ b/snapshots/session/cua-native/permission-fixture.mjs @@ -0,0 +1,23 @@ +/** Scripted human answers and a permission-page revocation in the recorded CUA scenario. */ +import { join } from 'node:path' +import { startFixtureHost } from './fixture-host.mjs' +export const name = 'cua-permission-fixture' +export const inject = ['userQuestions', 'cuaAccess', 'tools'] + +/** Supply external human decisions while retaining the real permission policy and tool pipeline. */ +export async function apply(ctx) { + const close = await startFixtureHost(join(process.cwd(), 'native-helper.mjs')) + ctx.effect(() => close) + ctx.on('user-questions/request', async (request) => ({ answers: request.questions.map(question => ({ + id: question.id, + selected: [question.question.includes('Denied Fixture') ? 'Deny' : 'Allow for this session'], + })) })) + ctx.on('tools/post-execute', async (execution, _result, next) => { + const decision = await next() + if (execution.name === 'cua' && execution.arguments.method === 'getApp' + && execution.arguments.params.selector === 'Revocable Fixture') { + await ctx.cuaAccess.revoke('c'.repeat(64)) + } + return decision + }) +} diff --git a/snapshots/session/cua-native/replay.override.json b/snapshots/session/cua-native/replay.override.json index 643fa5098e..2fefba26f5 100644 --- a/snapshots/session/cua-native/replay.override.json +++ b/snapshots/session/cua-native/replay.override.json @@ -32,6 +32,105 @@ } ] }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-unauthorized-getState", + "name": "cua", + "arguments": "{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-unauthorized-screenshot", + "name": "cua", + "arguments": "{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-unauthorized-click", + "name": "cua", + "arguments": "{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, { "kind": "chunks", "chunks": [ @@ -1616,6 +1715,105 @@ } ] }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-consent-denied", + "name": "cua", + "arguments": "{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-consent-revocable", + "name": "cua", + "arguments": "{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-consent-revoked", + "name": "cua", + "arguments": "{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index c20ab1a7bd..e7587237ab 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -2,261 +2,291 @@ {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect CUA capabilities, select the Fixture application, increment its counter once, then read fresh state and capture the window. Also inspect the AXless canvas window: capture it, dispatch one coordinate click at [20,20], and read its fresh empty AX state."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect CUA capabilities, select the Fixture application, increment its counter once, then read fresh state and capture the window. Also inspect the AXless canvas window: capture it, dispatch one coordinate click at [20,20], and read its fresh empty AX state. Finally verify a denied application selection and refusal of a handle after application access is revoked."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect CUA capabilities, select the Fixture application, increment its counter once, then read fresh state and capture the window. Also inspect the AXless canvas window: capture it, dispatch one coordinate click at [20,20], and read its fresh empty AX state."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect CUA capabilities, select the Fixture application, increment its counter once, then read fresh state and capture the window. Also inspect the AXless canvas window: capture it, dispatch one coordinate click at [20,20], and read its fresh empty AX state. Finally verify a denied application selection and refusal of a handle after application access is revoked."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554024,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554025,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1791947554025,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554025,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947688973,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947688973,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1791947688973,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947688973,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"platform\":\"macos\",\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"methods\":[\"getCapabilities\",\"listApps\",\"getApp\",\"activate\",\"show\",\"listWindows\",\"getState\",\"hover\",\"click\",\"setValue\",\"typeText\",\"paste\",\"pressKey\",\"scroll\",\"drag\",\"screenshot\"],\"coordinateSpace\":\"screenshot-pixels\",\"browser\":false}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554613,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554614,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}}},{"type":"chunk","time":1791947554614,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554614,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":2,"callId":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-2"},"toolCallId":"cua-native-2","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"name\":\"Fixture\",\"bundleId\":\"test.fixture\"}"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689573,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689573,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947689573,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689573,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-getState"},"toolCallId":"cua-unauthorized-getState","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554635,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554635,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}}},{"type":"chunk","time":1791947554635,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554635,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":3,"callId":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-3"},"toolCallId":"cua-native-3","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-1\",\"tree\":\"AX scope=window offsets=UTF-16 bounds=global-points\\n- [0] AXButton \\\"Increment\\\" id=\\\"increment\\\" actions=[\\\"AXPress\\\"] bounds=[60,80,100,20]\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 0\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\n - [3] AXMenuItem \\\"Fixture command\\\"\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689596,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689596,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947689596,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689596,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-screenshot"},"toolCallId":"cua-unauthorized-screenshot","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554651,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554651,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1791947554651,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554651,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":4,"callId":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0,\"holdMs\":800}}"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-4"},"toolCallId":"cua-native-4","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:11}}"}},"sourceEventSeqs":[29],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689615,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689615,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947689615,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689615,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0}}"}} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-click"},"toolCallId":"cua-unauthorized-click","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:11}}"}},"sourceEventSeqs":[29],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554667,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554667,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947554667,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554667,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":5,"callId":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-5"},"toolCallId":"cua-native-5","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-2\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:13}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689634,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689634,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}}},{"type":"chunk","time":1791947689634,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689634,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":5,"callId":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-2"},"toolCallId":"cua-native-2","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"name\":\"Fixture\",\"bundleId\":\"test.fixture\"}"}],"isError":false,"id":"{{message:13}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554684,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554684,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947554684,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554684,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":6,"callId":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":6,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-6"},"toolCallId":"cua-native-6","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:15}}"}},"sourceEventSeqs":[39],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689657,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689657,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}}},{"type":"chunk","time":1791947689657,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689657,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":6,"callId":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":6,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-3"},"toolCallId":"cua-native-3","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-1\",\"tree\":\"AX scope=window offsets=UTF-16 bounds=global-points\\n- [0] AXButton \\\"Increment\\\" id=\\\"increment\\\" actions=[\\\"AXPress\\\"] bounds=[60,80,100,20]\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 0\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\n - [3] AXMenuItem \\\"Fixture command\\\"\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:15}}"}},"sourceEventSeqs":[39],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:16}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554733,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554733,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947554733,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554733,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":7,"callId":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":7,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-7"},"toolCallId":"cua-native-7","content":[{"type":"text","text":"[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true},{\"windowId\":8,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}]"}],"isError":false,"id":"{{message:17}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:16}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689683,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689683,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1791947689683,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689683,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":7,"callId":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0,\"holdMs\":800}}"}} +{"type":"tool/result","data":{"turn":1,"step":7,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-4"},"toolCallId":"cua-native-4","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:17}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"step/start","data":{"turn":1,"step":8}} -{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:18}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554749,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554749,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1791947554749,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554749,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":8,"callId":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} -{"type":"tool/result","data":{"turn":1,"step":8,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-8"},"toolCallId":"cua-native-8","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:19}}"}},"sourceEventSeqs":[49],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:18}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689706,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689706,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947689706,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689706,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":8,"callId":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":8,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-5"},"toolCallId":"cua-native-5","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-2\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:19}}"}},"sourceEventSeqs":[49],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":8}} {"type":"step/start","data":{"turn":1,"step":9}} -{"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:20}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554765,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554765,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1791947554765,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554765,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":9,"callId":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} -{"type":"tool/result","data":{"turn":1,"step":9,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-9"},"toolCallId":"cua-native-9","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":8,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:21}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:20}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689729,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689729,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947689729,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689729,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":9,"callId":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":9,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-6"},"toolCallId":"cua-native-6","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:21}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":9}} {"type":"step/start","data":{"turn":1,"step":10}} -{"type":"assistant/message","data":{"turn":1,"step":10,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:22}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554791,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554791,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}}},{"type":"chunk","time":1791947554791,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554791,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":10,"callId":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}} -{"type":"tool/result","data":{"turn":1,"step":10,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-10"},"toolCallId":"cua-native-10","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:23}}"}},"sourceEventSeqs":[59],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":10,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:22}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689792,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689792,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947689792,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689792,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":10,"callId":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":10,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-7"},"toolCallId":"cua-native-7","content":[{"type":"text","text":"[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true},{\"windowId\":8,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}]"}],"isError":false,"id":"{{message:23}}"}},"sourceEventSeqs":[59],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":10}} {"type":"step/start","data":{"turn":1,"step":11}} -{"type":"assistant/message","data":{"turn":1,"step":11,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:24}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554807,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554807,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1791947554807,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554807,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":11,"callId":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} -{"type":"tool/result","data":{"turn":1,"step":11,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-11"},"toolCallId":"cua-native-11","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:25}}"}},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":11,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:24}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689834,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689834,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1791947689834,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689834,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":11,"callId":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} +{"type":"tool/result","data":{"turn":1,"step":11,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-8"},"toolCallId":"cua-native-8","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:25}}"}},"sourceEventSeqs":[64],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":11}} {"type":"step/start","data":{"turn":1,"step":12}} -{"type":"assistant/message","data":{"turn":1,"step":12,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:26}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554823,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554823,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947554823,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554823,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":12,"callId":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":12,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-show"},"toolCallId":"cua-native-show","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"windowId\":7,\"foregroundChanged\":false}"}],"isError":false,"id":"{{message:27}}"}},"sourceEventSeqs":[69],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":12,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:26}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689861,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689861,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1791947689861,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689861,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":12,"callId":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} +{"type":"tool/result","data":{"turn":1,"step":12,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-9"},"toolCallId":"cua-native-9","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":8,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:27}}"}},"sourceEventSeqs":[69],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":12}} {"type":"step/start","data":{"turn":1,"step":13}} -{"type":"assistant/message","data":{"turn":1,"step":13,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:28}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554839,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554839,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947554839,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554839,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":13,"callId":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":13,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-first"},"toolCallId":"cua-native-activate-first","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"foreground\":{\"requestId\":\"cua-native-activate-first\",\"operation\":\"activate\",\"targetPid\":9,\"targetWindowId\":null,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true,\"raiseRequested\":false}}"}],"isError":false,"id":"{{message:29}}"}},"sourceEventSeqs":[74],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":13,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:28}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689896,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689896,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}}},{"type":"chunk","time":1791947689896,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689896,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":13,"callId":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}} +{"type":"tool/result","data":{"turn":1,"step":13,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-10"},"toolCallId":"cua-native-10","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:29}}"}},"sourceEventSeqs":[74],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":13}} {"type":"step/start","data":{"turn":1,"step":14}} -{"type":"assistant/message","data":{"turn":1,"step":14,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:30}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554856,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554856,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947554856,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554856,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":14,"callId":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":14,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-repeat"},"toolCallId":"cua-native-activate-repeat","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"foreground\":{\"requestId\":\"cua-native-activate-repeat\",\"operation\":\"activate\",\"targetPid\":9,\"targetWindowId\":null,\"before\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":false,\"raiseRequested\":false}}"}],"isError":false,"id":"{{message:31}}"}},"sourceEventSeqs":[79],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":14,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:30}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689918,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689918,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1791947689918,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689918,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":14,"callId":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} +{"type":"tool/result","data":{"turn":1,"step":14,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-11"},"toolCallId":"cua-native-11","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:31}}"}},"sourceEventSeqs":[79],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":14}} {"type":"step/start","data":{"turn":1,"step":15}} -{"type":"assistant/message","data":{"turn":1,"step":15,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:32}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554872,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554872,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}}},{"type":"chunk","time":1791947554872,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554872,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":15,"callId":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":15,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-transition"},"toolCallId":"cua-native-transition","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:33}}"}},"sourceEventSeqs":[84],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":15,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:32}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689940,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689940,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947689940,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689940,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":15,"callId":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":15,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-show"},"toolCallId":"cua-native-show","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"windowId\":7,\"foregroundChanged\":false}"}],"isError":false,"id":"{{message:33}}"}},"sourceEventSeqs":[84],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":15}} {"type":"step/start","data":{"turn":1,"step":16}} -{"type":"assistant/message","data":{"turn":1,"step":16,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:34}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554888,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554888,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947554888,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554888,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":16,"callId":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":16,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-read"},"toolCallId":"cua-native-stale-read","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Accessibility tree changed during observation; read fresh getAXState"}],"isError":true,"id":"{{message:35}}"}},"sourceEventSeqs":[89],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":16,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:34}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689961,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689961,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947689961,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689961,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":16,"callId":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":16,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-first"},"toolCallId":"cua-native-activate-first","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"foreground\":{\"requestId\":\"cua-native-activate-first\",\"operation\":\"activate\",\"targetPid\":9,\"targetWindowId\":null,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true,\"raiseRequested\":false}}"}],"isError":false,"id":"{{message:35}}"}},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":16}} {"type":"step/start","data":{"turn":1,"step":17}} -{"type":"assistant/message","data":{"turn":1,"step":17,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:36}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554904,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554904,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947554904,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554904,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":17,"callId":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementIndex\":0}}"}} -{"type":"tool/result","data":{"turn":1,"step":17,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-index"},"toolCallId":"cua-native-stale-index","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Read fresh state before acting"}],"isError":true,"id":"{{message:37}}"}},"sourceEventSeqs":[94],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":17,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:36}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947689983,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947689983,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947689983,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947689983,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":17,"callId":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":17,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-repeat"},"toolCallId":"cua-native-activate-repeat","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"foreground\":{\"requestId\":\"cua-native-activate-repeat\",\"operation\":\"activate\",\"targetPid\":9,\"targetWindowId\":null,\"before\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":false,\"raiseRequested\":false}}"}],"isError":false,"id":"{{message:37}}"}},"sourceEventSeqs":[94],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":17}} {"type":"step/start","data":{"turn":1,"step":18}} -{"type":"assistant/message","data":{"turn":1,"step":18,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:38}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554920,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554920,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1791947554920,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554920,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":18,"callId":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}} -{"type":"tool/result","data":{"turn":1,"step":18,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-other-window"},"toolCallId":"cua-native-other-window","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:39}}"}},"sourceEventSeqs":[99],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":18,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:38}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690002,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690002,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}}},{"type":"chunk","time":1791947690002,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690002,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":18,"callId":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":18,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-transition"},"toolCallId":"cua-native-transition","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:39}}"}},"sourceEventSeqs":[99],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":18}} {"type":"step/start","data":{"turn":1,"step":19}} -{"type":"assistant/message","data":{"turn":1,"step":19,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:40}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554936,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554936,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947554936,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554936,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":19,"callId":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":19,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-read"},"toolCallId":"cua-native-fresh-read","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-5\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:41}}"}},"sourceEventSeqs":[104],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":19,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:40}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690019,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690019,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690019,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690019,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":19,"callId":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":19,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-read"},"toolCallId":"cua-native-stale-read","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Accessibility tree changed during observation; read fresh getAXState"}],"isError":true,"id":"{{message:41}}"}},"sourceEventSeqs":[104],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":19}} {"type":"step/start","data":{"turn":1,"step":20}} -{"type":"assistant/message","data":{"turn":1,"step":20,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:42}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554954,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554954,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947554954,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554954,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":20,"callId":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementIndex\":0}}"}} -{"type":"tool/result","data":{"turn":1,"step":20,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-click"},"toolCallId":"cua-native-fresh-click","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:43}}"}},"sourceEventSeqs":[109],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":20,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:42}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690040,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690040,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947690040,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690040,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":20,"callId":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementIndex\":0}}"}} +{"type":"tool/result","data":{"turn":1,"step":20,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-index"},"toolCallId":"cua-native-stale-index","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Read fresh state before acting"}],"isError":true,"id":"{{message:43}}"}},"sourceEventSeqs":[109],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":20}} {"type":"step/start","data":{"turn":1,"step":21}} -{"type":"assistant/message","data":{"turn":1,"step":21,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:44}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554970,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554970,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947554970,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554970,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":21,"callId":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":21,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-verify"},"toolCallId":"cua-native-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-6\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:45}}"}},"sourceEventSeqs":[114],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":21,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:44}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690064,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690064,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1791947690064,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690064,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":21,"callId":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}} +{"type":"tool/result","data":{"turn":1,"step":21,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-other-window"},"toolCallId":"cua-native-other-window","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:45}}"}},"sourceEventSeqs":[114],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":21}} {"type":"step/start","data":{"turn":1,"step":22}} -{"type":"assistant/message","data":{"turn":1,"step":22,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:46}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947554987,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947554987,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1791947554987,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947554987,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":22,"callId":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} -{"type":"tool/result","data":{"turn":1,"step":22,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-observe"},"toolCallId":"cua-native-geometry-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:47}}"}},"sourceEventSeqs":[119],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":22,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:46}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690087,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690087,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690087,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690087,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":22,"callId":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":22,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-read"},"toolCallId":"cua-native-fresh-read","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-5\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:47}}"}},"sourceEventSeqs":[119],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":22}} {"type":"step/start","data":{"turn":1,"step":23}} -{"type":"assistant/message","data":{"turn":1,"step":23,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:48}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555003,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555003,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1791947555003,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555003,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":23,"callId":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} -{"type":"tool/result","data":{"turn":1,"step":23,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-capture"},"toolCallId":"cua-native-geometry-capture","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:49}}"}},"sourceEventSeqs":[124],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":23,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:48}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690111,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690111,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947690111,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690111,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":23,"callId":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementIndex\":0}}"}} +{"type":"tool/result","data":{"turn":1,"step":23,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-click"},"toolCallId":"cua-native-fresh-click","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:49}}"}},"sourceEventSeqs":[124],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":23}} {"type":"step/start","data":{"turn":1,"step":24}} -{"type":"assistant/message","data":{"turn":1,"step":24,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:50}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555020,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555020,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}}},{"type":"chunk","time":1791947555020,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555020,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":24,"callId":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}} -{"type":"tool/result","data":{"turn":1,"step":24,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-click"},"toolCallId":"cua-native-geometry-click","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:51}}"}},"sourceEventSeqs":[129],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":24,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:50}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690141,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690141,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690141,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690141,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":24,"callId":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":24,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-verify"},"toolCallId":"cua-native-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-6\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:51}}"}},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":24}} {"type":"step/start","data":{"turn":1,"step":25}} -{"type":"assistant/message","data":{"turn":1,"step":25,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:52}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555037,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555037,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}}},{"type":"chunk","time":1791947555037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555037,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":25,"callId":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}} -{"type":"tool/result","data":{"turn":1,"step":25,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-scroll"},"toolCallId":"cua-native-geometry-scroll","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:53}}"}},"sourceEventSeqs":[134],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":25,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:52}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690162,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690162,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1791947690162,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690162,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":25,"callId":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} +{"type":"tool/result","data":{"turn":1,"step":25,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-observe"},"toolCallId":"cua-native-geometry-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:53}}"}},"sourceEventSeqs":[134],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":25}} {"type":"step/start","data":{"turn":1,"step":26}} -{"type":"assistant/message","data":{"turn":1,"step":26,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:54}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555054,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555054,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}}},{"type":"chunk","time":1791947555054,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555054,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":26,"callId":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}} -{"type":"tool/result","data":{"turn":1,"step":26,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-drag"},"toolCallId":"cua-native-geometry-drag","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:55}}"}},"sourceEventSeqs":[139],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":26,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:54}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690186,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690186,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1791947690186,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690186,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":26,"callId":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} +{"type":"tool/result","data":{"turn":1,"step":26,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-capture"},"toolCallId":"cua-native-geometry-capture","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:55}}"}},"sourceEventSeqs":[139],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":26}} {"type":"step/start","data":{"turn":1,"step":27}} -{"type":"assistant/message","data":{"turn":1,"step":27,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:56}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555071,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555071,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947555071,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555071,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":27,"callId":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementIndex\":0}}"}} -{"type":"tool/result","data":{"turn":1,"step":27,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-ax"},"toolCallId":"cua-native-geometry-ax","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:57}}"}},"sourceEventSeqs":[144],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":27,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:56}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690221,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690221,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}}},{"type":"chunk","time":1791947690221,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690221,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":27,"callId":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":27,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-click"},"toolCallId":"cua-native-geometry-click","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:57}}"}},"sourceEventSeqs":[144],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":27}} {"type":"step/start","data":{"turn":1,"step":28}} -{"type":"assistant/message","data":{"turn":1,"step":28,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:58}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555087,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555087,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1791947555087,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555087,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":28,"callId":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} -{"type":"tool/result","data":{"turn":1,"step":28,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-verify"},"toolCallId":"cua-native-geometry-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":9,\"snapshotId\":\"fixture-snapshot-8\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:59}}"}},"sourceEventSeqs":[149],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":28,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:58}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690248,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690248,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}}},{"type":"chunk","time":1791947690248,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690248,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":28,"callId":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":28,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-scroll"},"toolCallId":"cua-native-geometry-scroll","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:59}}"}},"sourceEventSeqs":[149],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":28}} {"type":"step/start","data":{"turn":1,"step":29}} -{"type":"assistant/message","data":{"turn":1,"step":29,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:60}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555103,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555103,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}}},{"type":"chunk","time":1791947555103,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555103,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":29,"callId":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":29,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-arm"},"toolCallId":"cua-capture-busy-arm","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:61}}"}},"sourceEventSeqs":[154],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":29,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:60}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690267,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690267,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}}},{"type":"chunk","time":1791947690267,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690267,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":29,"callId":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":29,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-drag"},"toolCallId":"cua-native-geometry-drag","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:61}}"}},"sourceEventSeqs":[154],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":29}} {"type":"step/start","data":{"turn":1,"step":30}} -{"type":"assistant/message","data":{"turn":1,"step":30,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:62}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555120,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555120,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555120,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555120,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":30,"callId":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":30,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy"},"toolCallId":"cua-capture-busy","content":[{"type":"text","text":"Error: CAPTURE_BUSY: A previous system capture is still pending; other native operations remain available\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-capture-busy\",\"stage\":\"helper.failed\",\"elapsedMs\":1,\"errorCode\":\"CAPTURE_BUSY\"}"}],"isError":true,"id":"{{message:63}}"}},"sourceEventSeqs":[159],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":30,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:62}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690294,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690294,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947690294,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690294,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":30,"callId":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementIndex\":0}}"}} +{"type":"tool/result","data":{"turn":1,"step":30,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-ax"},"toolCallId":"cua-native-geometry-ax","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:63}}"}},"sourceEventSeqs":[159],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":30}} {"type":"step/start","data":{"turn":1,"step":31}} -{"type":"assistant/message","data":{"turn":1,"step":31,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:64}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555141,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555141,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555141,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555141,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":31,"callId":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":31,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-ax"},"toolCallId":"cua-capture-busy-ax","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-9\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:65}}"}},"sourceEventSeqs":[164],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":31,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:64}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690314,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690314,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1791947690314,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690314,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":31,"callId":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} +{"type":"tool/result","data":{"turn":1,"step":31,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-verify"},"toolCallId":"cua-native-geometry-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":9,\"snapshotId\":\"fixture-snapshot-8\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:65}}"}},"sourceEventSeqs":[164],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":31}} {"type":"step/start","data":{"turn":1,"step":32}} -{"type":"assistant/message","data":{"turn":1,"step":32,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:66}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555157,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555157,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}}},{"type":"chunk","time":1791947555157,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555157,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":32,"callId":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":32,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-callback"},"toolCallId":"cua-capture-callback","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:67}}"}},"sourceEventSeqs":[169],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":32,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:66}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690340,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690340,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}}},{"type":"chunk","time":1791947690340,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690340,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":32,"callId":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":32,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-arm"},"toolCallId":"cua-capture-busy-arm","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:67}}"}},"sourceEventSeqs":[169],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":32}} {"type":"step/start","data":{"turn":1,"step":33}} -{"type":"assistant/message","data":{"turn":1,"step":33,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:68}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555173,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555173,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555173,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555173,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":33,"callId":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":33,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-recovered"},"toolCallId":"cua-capture-recovered","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:69}}"}},"sourceEventSeqs":[174],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":33,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:68}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690365,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690365,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690365,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690365,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":33,"callId":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":33,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy"},"toolCallId":"cua-capture-busy","content":[{"type":"text","text":"Error: CAPTURE_BUSY: A previous system capture is still pending; other native operations remain available\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-capture-busy\",\"stage\":\"helper.failed\",\"elapsedMs\":1,\"errorCode\":\"CAPTURE_BUSY\"}"}],"isError":true,"id":"{{message:69}}"}},"sourceEventSeqs":[174],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":33}} {"type":"step/start","data":{"turn":1,"step":34}} -{"type":"assistant/message","data":{"turn":1,"step":34,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:70}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555199,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555199,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947555199,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555199,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":34,"callId":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementIndex\":0}}"}} -{"type":"tool/result","data":{"turn":1,"step":34,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-enter"},"toolCallId":"cua-hover-enter","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:71}}"}},"sourceEventSeqs":[179],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":34,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:70}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690394,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690394,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690394,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690394,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":34,"callId":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":34,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-ax"},"toolCallId":"cua-capture-busy-ax","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-9\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:71}}"}},"sourceEventSeqs":[179],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":34}} {"type":"step/start","data":{"turn":1,"step":35}} -{"type":"assistant/message","data":{"turn":1,"step":35,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:72}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555215,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555215,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555215,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555216,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":35,"callId":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":35,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-observe"},"toolCallId":"cua-hover-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- AXStaticText \\\"Hover information\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:73}}"}},"sourceEventSeqs":[184],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":35,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:72}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690414,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690414,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}}},{"type":"chunk","time":1791947690414,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690414,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":35,"callId":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":35,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-callback"},"toolCallId":"cua-capture-callback","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:73}}"}},"sourceEventSeqs":[184],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":35}} {"type":"step/start","data":{"turn":1,"step":36}} -{"type":"assistant/message","data":{"turn":1,"step":36,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:74}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555231,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555231,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555231,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555231,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":36,"callId":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":36,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-capture"},"toolCallId":"cua-hover-capture","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:75}}"}},"sourceEventSeqs":[189],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":36,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:74}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690434,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690434,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690434,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690434,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":36,"callId":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":36,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-recovered"},"toolCallId":"cua-capture-recovered","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:75}}"}},"sourceEventSeqs":[189],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":36}} {"type":"step/start","data":{"turn":1,"step":37}} -{"type":"assistant/message","data":{"turn":1,"step":37,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:76}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555256,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555256,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1791947555256,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555256,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":37,"callId":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}} -{"type":"tool/result","data":{"turn":1,"step":37,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-popup"},"toolCallId":"cua-hover-popup","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:77}}"}},"sourceEventSeqs":[194],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":37,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementIndex\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:76}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690466,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690466,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementIndex\":0}}"}}},{"type":"chunk","time":1791947690466,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690466,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":37,"callId":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementIndex\":0}}"}} +{"type":"tool/result","data":{"turn":1,"step":37,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-enter"},"toolCallId":"cua-hover-enter","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:77}}"}},"sourceEventSeqs":[194],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":37}} {"type":"step/start","data":{"turn":1,"step":38}} -{"type":"assistant/message","data":{"turn":1,"step":38,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:78}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555273,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555273,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1791947555273,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555273,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":38,"callId":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}} -{"type":"tool/result","data":{"turn":1,"step":38,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-click"},"toolCallId":"cua-hover-click","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:79}}"}},"sourceEventSeqs":[199],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":38,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:78}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690495,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690495,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690495,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690495,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":38,"callId":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":38,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-observe"},"toolCallId":"cua-hover-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- AXStaticText \\\"Hover information\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:79}}"}},"sourceEventSeqs":[199],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":38}} {"type":"step/start","data":{"turn":1,"step":39}} -{"type":"assistant/message","data":{"turn":1,"step":39,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:80}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555291,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555291,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1791947555291,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555291,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":39,"callId":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}} -{"type":"tool/result","data":{"turn":1,"step":39,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-exit"},"toolCallId":"cua-hover-exit","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:81}}"}},"sourceEventSeqs":[204],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":39,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:80}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690517,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690517,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690517,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690517,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":39,"callId":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":39,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-capture"},"toolCallId":"cua-hover-capture","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:81}}"}},"sourceEventSeqs":[204],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":39}} {"type":"step/start","data":{"turn":1,"step":40}} -{"type":"assistant/message","data":{"turn":1,"step":40,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:82}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555307,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555307,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555307,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555307,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":40,"callId":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":40,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-verify"},"toolCallId":"cua-hover-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-11\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:83}}"}},"sourceEventSeqs":[209],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":40,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:82}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690565,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690565,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1791947690565,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690565,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":40,"callId":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":40,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-popup"},"toolCallId":"cua-hover-popup","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:83}}"}},"sourceEventSeqs":[209],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":40}} {"type":"step/start","data":{"turn":1,"step":41}} -{"type":"assistant/message","data":{"turn":1,"step":41,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:84}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555323,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555323,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}}},{"type":"chunk","time":1791947555323,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555323,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":41,"callId":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":41,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-background"},"toolCallId":"cua-paste-background","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:85}}"}},"sourceEventSeqs":[214],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":41,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:84}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690590,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690590,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1791947690590,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690590,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":41,"callId":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":41,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-click"},"toolCallId":"cua-hover-click","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:85}}"}},"sourceEventSeqs":[214],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":41}} {"type":"step/start","data":{"turn":1,"step":42}} -{"type":"assistant/message","data":{"turn":1,"step":42,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:86}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555339,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555339,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1791947555339,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555339,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":42,"callId":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":42,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-unread"},"toolCallId":"cua-paste-unread","content":[{"type":"text","text":"Error: PASTE_NOT_CONSUMED: Paste command was dispatched, but clipboard data was not requested within the read deadline. Bring the target app to the foreground and focus its text input. Inspect the contents before deciding whether to retry; a delayed paste may still run."}],"isError":true,"id":"{{message:87}}"}},"sourceEventSeqs":[219],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":42,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:86}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690613,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690613,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1791947690613,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690613,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":42,"callId":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"hover\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":42,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-exit"},"toolCallId":"cua-hover-exit","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:87}}"}},"sourceEventSeqs":[219],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":42}} {"type":"step/start","data":{"turn":1,"step":43}} -{"type":"assistant/message","data":{"turn":1,"step":43,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:88}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555356,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555356,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555356,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555356,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":43,"callId":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":43,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-inspect"},"toolCallId":"cua-paste-inspect","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-12\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:89}}"}},"sourceEventSeqs":[224],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":43,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:88}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690633,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690633,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690633,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690633,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":43,"callId":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":43,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-verify"},"toolCallId":"cua-hover-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-11\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:89}}"}},"sourceEventSeqs":[224],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":43}} {"type":"step/start","data":{"turn":1,"step":44}} -{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555372,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555372,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947555372,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555372,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-activate"},"toolCallId":"cua-paste-activate","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"foreground\":{\"requestId\":\"cua-paste-activate\",\"operation\":\"activate\",\"targetPid\":9,\"targetWindowId\":null,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true,\"raiseRequested\":false}}"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690653,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690653,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}}},{"type":"chunk","time":1791947690653,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690653,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-background"},"toolCallId":"cua-paste-background","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":44}} {"type":"step/start","data":{"turn":1,"step":45}} -{"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555388,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555388,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555388,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555388,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":45,"callId":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":45,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-focus"},"toolCallId":"cua-paste-focus","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-13\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:93}}"}},"sourceEventSeqs":[234],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690672,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690672,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1791947690672,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690672,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":45,"callId":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":45,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-unread"},"toolCallId":"cua-paste-unread","content":[{"type":"text","text":"Error: PASTE_NOT_CONSUMED: Paste command was dispatched, but clipboard data was not requested within the read deadline. Bring the target app to the foreground and focus its text input. Inspect the contents before deciding whether to retry; a delayed paste may still run."}],"isError":true,"id":"{{message:93}}"}},"sourceEventSeqs":[234],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":45}} {"type":"step/start","data":{"turn":1,"step":46}} -{"type":"assistant/message","data":{"turn":1,"step":46,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:94}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555404,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555404,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1791947555404,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555404,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":46,"callId":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":46,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-consumed"},"toolCallId":"cua-paste-consumed","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:95}}"}},"sourceEventSeqs":[239],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":46,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:94}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690696,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690696,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690696,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690696,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":46,"callId":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":46,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-inspect"},"toolCallId":"cua-paste-inspect","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-12\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:95}}"}},"sourceEventSeqs":[239],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":46}} {"type":"step/start","data":{"turn":1,"step":47}} -{"type":"assistant/message","data":{"turn":1,"step":47,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:96}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555421,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555421,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555421,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555421,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":47,"callId":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":47,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-verify"},"toolCallId":"cua-paste-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-14\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"fixture paste\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:97}}"}},"sourceEventSeqs":[244],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":47,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:96}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690714,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690714,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1791947690714,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690714,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":47,"callId":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":47,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-activate"},"toolCallId":"cua-paste-activate","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false,\"foreground\":{\"requestId\":\"cua-paste-activate\",\"operation\":\"activate\",\"targetPid\":9,\"targetWindowId\":null,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true,\"raiseRequested\":false}}"}],"isError":false,"id":"{{message:97}}"}},"sourceEventSeqs":[244],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":47}} {"type":"step/start","data":{"turn":1,"step":48}} -{"type":"assistant/message","data":{"turn":1,"step":48,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:98}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555437,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555437,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}}},{"type":"chunk","time":1791947555437,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555437,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":48,"callId":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":48,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-arm"},"toolCallId":"cua-debug-arm","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:99}}"}},"sourceEventSeqs":[249],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":48,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:98}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690734,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690734,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690734,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690734,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":48,"callId":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":48,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-focus"},"toolCallId":"cua-paste-focus","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-13\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:99}}"}},"sourceEventSeqs":[249],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":48}} {"type":"step/start","data":{"turn":1,"step":49}} -{"type":"assistant/message","data":{"turn":1,"step":49,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:100}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555453,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947555453,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947555453,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555453,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":49,"callId":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":49,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-screenshot"},"toolCallId":"cua-debug-screenshot","content":[{"type":"text","text":"Error: NATIVE_PROTOCOL: Helper returned an invalid response; acquire fresh application handles\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-debug-screenshot\",\"stage\":\"capture-image.begin\",\"elapsedMs\":1}"}],"isError":true,"id":"{{message:101}}"}},"sourceEventSeqs":[254],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":49,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:100}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690757,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690757,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1791947690757,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690757,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":49,"callId":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":49,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-consumed"},"toolCallId":"cua-paste-consumed","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:101}}"}},"sourceEventSeqs":[254],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":49}} {"type":"step/start","data":{"turn":1,"step":50}} -{"type":"assistant/message","data":{"turn":1,"step":50,"message":{"role":"assistant","content":[{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:102}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947555473,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791947555473,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}}},{"type":"chunk","time":1791947555473,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947555473,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":50,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:102}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690778,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690778,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690778,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690778,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":50,"callId":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":50,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-verify"},"toolCallId":"cua-paste-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"pid\":9,\"windowId\":7,\"snapshotId\":\"fixture-snapshot-14\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"fixture paste\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"pid\":9,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:103}}"}},"sourceEventSeqs":[259],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":50}} +{"type":"step/start","data":{"turn":1,"step":51}} +{"type":"assistant/message","data":{"turn":1,"step":51,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:104}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690801,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690801,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}}},{"type":"chunk","time":1791947690801,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690801,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":51,"callId":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":51,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-arm"},"toolCallId":"cua-debug-arm","content":[{"type":"text","text":"{\"dispatched\":true,\"verified\":false}"}],"isError":false,"id":"{{message:105}}"}},"sourceEventSeqs":[264],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":51}} +{"type":"step/start","data":{"turn":1,"step":52}} +{"type":"assistant/message","data":{"turn":1,"step":52,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:106}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690821,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690821,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1791947690821,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690821,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":52,"callId":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":52,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-screenshot"},"toolCallId":"cua-debug-screenshot","content":[{"type":"text","text":"Error: NATIVE_PROTOCOL: Helper returned an invalid response; acquire fresh application handles\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-debug-screenshot\",\"stage\":\"capture-image.begin\",\"elapsedMs\":1}"}],"isError":true,"id":"{{message:107}}"}},"sourceEventSeqs":[269],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":52}} +{"type":"step/start","data":{"turn":1,"step":53}} +{"type":"assistant/message","data":{"turn":1,"step":53,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:108}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947690852,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947690852,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}}},{"type":"chunk","time":1791947690852,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947690852,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":53,"callId":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":53,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-denied"},"toolCallId":"cua-consent-denied","content":[{"type":"text","text":"Error: APP_ACCESS_DENIED: The user denied access to Denied Fixture. Do not retry or use another tool to bypass this decision."}],"isError":true,"id":"{{message:109}}"}},"sourceEventSeqs":[274],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":53}} +{"type":"step/start","data":{"turn":1,"step":54}} +{"type":"assistant/message","data":{"turn":1,"step":54,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:110}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947691022,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947691022,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}}},{"type":"chunk","time":1791947691022,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947691022,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":54,"callId":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":54,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revocable"},"toolCallId":"cua-consent-revocable","content":[{"type":"text","text":"{\"appHandle\":\"revoked-app\",\"pid\":17,\"name\":\"Revocable Fixture\"}"}],"isError":false,"id":"{{message:111}}"}},"sourceEventSeqs":[279],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":54}} +{"type":"step/start","data":{"turn":1,"step":55}} +{"type":"assistant/message","data":{"turn":1,"step":55,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:112}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947691192,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791947691192,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}}},{"type":"chunk","time":1791947691192,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947691192,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":55,"callId":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":55,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revoked"},"toolCallId":"cua-consent-revoked","content":[{"type":"text","text":"Error: APP_ACCESS_REVOKED: Application access was revoked; stop using this application"}],"isError":true,"id":"{{message:113}}"}},"sourceEventSeqs":[284],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":55}} +{"type":"step/start","data":{"turn":1,"step":56}} +{"type":"assistant/message","data":{"turn":1,"step":56,"message":{"role":"assistant","content":[{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:114}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791947691212,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791947691212,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}}},{"type":"chunk","time":1791947691212,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791947691212,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":56}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 885e9058aa..d2daf65630 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -29,7 +29,7 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Start independent delegations with `subagent` or `subagent_fork` together in one assistant message and continue useful work while they run. -Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly grants access again. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. +Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly permits another authorization request. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. Initialize the object API once in persistent PTC. Reuse its variables in later calls; put temporary declarations inside { ... } blocks to avoid redeclaration errors. All object methods are async. getAXState returns text, not a structured state object. Its text separates values and placeholders, and reports subroles, help, URLs, disabled/focused/selected/expanded state and UTF-16 selection ranges when available. Writability is true, false or unknown on relevant controls; omitted state fields do not assert false. selection=unavailable means the reported range exceeds the observed text. Compact output omits ordinary Press/Pick (click), ShowMenu (right click) and ScrollToVisible actions; details=true lists every available action. Use the displayed action name with performSecondaryAction; ambiguous aliases require the complete name from details=true. Truncation reports the reached provider limit. Narrow the window or increase that provider limit before reading again. @@ -66,7 +66,7 @@ App and Window methods: Raw operation tools use { method, params }, including params: {} for parameterless methods. getApp selects without activating the app. Keep applications in the background for observation and routine input. Do not activate or raise an app during initialization, before each action, or merely because another app is foreground. Prefer advertised AX actions. If a window is hidden or minimized, try app.show() or window.show() and read fresh state. show requests unhide and unminimize without activation, raising or switching Spaces; it chooses the explicit, focused, main or sole AX window. A null windowId means no window was selected for restoration. foregroundChanged compares the front app and top window before and after the request; null means unavailable, and delayed focus changes remain possible. If restoration is unsupported or the window remains unusable, including another Space or Stage Manager, explain the needed foreground step, use app.activate() or an advertised raise action, and read fresh state. Completing the task takes priority when foreground access is necessary. Avoid repeated activation when the user switches away; continue background-capable steps and report what requires foreground access. Applications may still change focus in response to input. -Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant macOS Accessibility or Screen Recording access in System Settings. After granting access, observe fresh state before continuing; do not automatically replay prior input. +Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant the required macOS system permissions for macOS Accessibility or Screen Recording. After granting access, observe fresh state before continuing; do not automatically replay prior input. Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. diff --git a/snapshots/session/cua-native/workspace/native-helper.mjs b/snapshots/session/cua-native/workspace/native-helper.mjs index c4510416c3..e36445ca94 100755 --- a/snapshots/session/cua-native/workspace/native-helper.mjs +++ b/snapshots/session/cua-native/workspace/native-helper.mjs @@ -125,6 +125,12 @@ function createApplicationState(socket, record, application, captureBroker, conn function refuse(code, message) { throw Object.assign(new Error(message), { code }) } function call(requestId, method, params) { record('call', { requestId, method, params }) + if (method === 'applicationIcons') return Object.fromEntries(params.paths.map(path => [path, `data:image/png;base64,${png}`])) + if (method === 'openPermissionSettings') return { opened: true } + if (method === 'permissionStatus' || method === 'requestPermission') { + const state = JSON.parse(readFileSync(new URL('./permissions.json', import.meta.url), 'utf8')) + return { ...state, helper: { pid: process.pid }, parent: { pid: process.ppid } } + } if (method === 'getCapabilities') return { platform: 'macos', sessionLocked: false, permissions: { accessibility: true, screenRecording: true }, methods, coordinateSpace: 'window-points', browser: false } if (method === 'listApps') return [{ pid: app.pid, name: app.name, bundleId: app.bundleId }] if (method === 'getApp') { diff --git a/snapshots/session/cua-ptc/consent-fixture.mjs b/snapshots/session/cua-ptc/consent-fixture.mjs index 3e6dd4064c..85e83bcf69 100644 --- a/snapshots/session/cua-ptc/consent-fixture.mjs +++ b/snapshots/session/cua-ptc/consent-fixture.mjs @@ -15,6 +15,6 @@ export async function apply(ctx) { try { await close() } finally { await unlink(helper) } }) ctx.on('user-questions/request', async request => ({ answers: request.questions.map(question => ({ - id: question.id, selected: ['Allow for this task'], + id: question.id, selected: ['Allow for this session'], })) })) } diff --git a/snapshots/session/cua-ptc/cordis.snapshot.yml b/snapshots/session/cua-ptc/cordis.snapshot.yml index c2f9b23c4b..e42a3ce149 100644 --- a/snapshots/session/cua-ptc/cordis.snapshot.yml +++ b/snapshots/session/cua-ptc/cordis.snapshot.yml @@ -50,6 +50,7 @@ - id: cua-native name: '@deepseek-ai/dsh-experimental-cua-macos' config: + agentPreset: false helperPath: !!js process.cwd() + '/native-helper.mjs' - id: user-questions diff --git a/snapshots/session/cua-ptc/cordis.yml b/snapshots/session/cua-ptc/cordis.yml index 60a3be9c9e..ac932bb223 100644 --- a/snapshots/session/cua-ptc/cordis.yml +++ b/snapshots/session/cua-ptc/cordis.yml @@ -32,6 +32,7 @@ - id: cua-native name: '@deepseek-ai/dsh-experimental-cua-macos' config: + agentPreset: false helperPath: !!js process.cwd() + '/native-helper.mjs' - id: user-questions diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 5bc067211a..cf5222fd7f 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -31,7 +31,7 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Start independent delegations with `subagent` or `subagent_fork` together in one assistant message and continue useful work while they run. -Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly grants access again. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. +Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps or cua.getState to discover running applications, and findApps(query) to search installed applications by name, bundle ID or absolute path. getApp requests user consent before launching a stopped application in the background, or before observations and input on a running one. If access is denied or revoked, stop using that application until the user explicitly permits another authorization request. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. Raw getState returns AX text and snapshotId. Menu-bar entries are shown by default; includeMenus=true expands their contents. details=true adds identifiers, global-point bounds and complete action names. Both options also apply to getAXState. Each read replaces the indices for that app/window scope. Use its element indices only with that snapshotId. Raw screenshot and coordinate operations require a windowId. Coordinates are pixels in the latest returned screenshot, measured from its top-left. Set relative:true for integer thousandths from 0 to 1000 instead. The Host converts both modes to native window points; AX detail bounds remain global macOS points. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation. Initialize the object API once in persistent PTC. Reuse its variables in later calls; put temporary declarations inside { ... } blocks to avoid redeclaration errors. All object methods are async. getAXState returns text, not a structured state object. Its text separates values and placeholders, and reports subroles, help, URLs, disabled/focused/selected/expanded state and UTF-16 selection ranges when available. Writability is true, false or unknown on relevant controls; omitted state fields do not assert false. selection=unavailable means the reported range exceeds the observed text. Compact output omits ordinary Press/Pick (click), ShowMenu (right click) and ScrollToVisible actions; details=true lists every available action. Use the displayed action name with performSecondaryAction; ambiguous aliases require the complete name from details=true. Truncation reports the reached provider limit. Narrow the window or increase that provider limit before reading again. @@ -68,7 +68,7 @@ App and Window methods: Raw operation tools use { method, params }, including params: {} for parameterless methods. getApp selects without activating the app. Keep applications in the background for observation and routine input. Do not activate or raise an app during initialization, before each action, or merely because another app is foreground. Prefer advertised AX actions. If a window is hidden or minimized, try app.show() or window.show() and read fresh state. show requests unhide and unminimize without activation, raising or switching Spaces; it chooses the explicit, focused, main or sole AX window. A null windowId means no window was selected for restoration. foregroundChanged compares the front app and top window before and after the request; null means unavailable, and delayed focus changes remain possible. If restoration is unsupported or the window remains unusable, including another Space or Stage Manager, explain the needed foreground step, use app.activate() or an advertised raise action, and read fresh state. Completing the task takes priority when foreground access is necessary. Avoid repeated activation when the user switches away; continue background-capable steps and report what requires foreground access. Applications may still change focus in response to input. -Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant macOS Accessibility or Screen Recording access in System Settings. After granting access, observe fresh state before continuing; do not automatically replay prior input. +Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. Absolute coordinate input requires a fresh screenshot of that window. Relative input can use a successful AX observation or screenshot. SCREENSHOT_REQUIRED means capture a fresh screenshot before using pixel coordinates. For click and hover, coordinate targets must be [x, y], for example hover([650, 400], {relative:true}). A bare number such as hover(650) is always an AX element index. The relative option applies only to coordinate targets, not AX indices. Window resizing since the latest observation rejects coordinate input; movement alone preserves window-local coordinates. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant the required macOS system permissions for macOS Accessibility or Screen Recording. After granting access, observe fresh state before continuing; do not automatically replay prior input. Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 503b64ecd9..9c039898f0 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -1334,6 +1334,14 @@ describe('headless recorded-session snapshots', () => { }, inspect: async (cwd) => { actualLogs = await persistedSessions(cwd) + if (scenario.name === 'empty-response-retry-current') { + const events = parseSessionLog(actualLogs[0]!.content) + expect(events.filter(event => event.type === 'assistant/attempt' || event.type === 'llm/retry' + || event.type === 'llm/retry-started' || event.type === 'assistant/message') + .map(event => event.type)).toEqual([ + 'assistant/attempt', 'llm/retry', 'llm/retry-started', 'assistant/message', + ]) + } if (retainedToolInput !== undefined) { verifyToolResultWriterParity(primaryFixture, actualLogs[0]!.content) } diff --git a/tsconfig.base.json b/tsconfig.base.json index 1de10c3186..4f6eaa1d25 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -231,6 +231,7 @@ "@deepseek-ai/dsh-plugin-manager/tools": ["./packages/boot/plugin-manager/src/tools.ts"], "@deepseek-ai/dsh-plugin-market/types": ["./packages/boot/plugin-market/src/types.ts"], "@deepseek-ai/dsh-plugin-market/tools": ["./packages/boot/plugin-market/src/tools.ts"], + "@deepseek-ai/dsh-plugin-detail/types": ["./packages/util/plugin-detail/src/types.ts"], "@deepseek-ai/dsh-tool-cordis/host": ["./packages/extensions/tool-cordis/src/host.ts"], "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], @@ -302,6 +303,8 @@ "@deepseek-ai/dsh-experimental-client-ui-agent-team/client": ["./packages/experimental/client-ui-agent-team/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-settings-shell": ["./packages/client/ui-settings-shell/src"], + "@deepseek-ai/dsh-cua/client": ["./packages/experimental/cua/src/client/index.ts"], + "@deepseek-ai/dsh-cua/types": ["./packages/experimental/cua/src/remote-types.ts"], "@deepseek-ai/dsh-client-ui-settings-agent-loop": ["./packages/client/ui-settings-agent-loop/src"], "@deepseek-ai/dsh-client-ui-settings-session-log": ["./packages/client/ui-settings-session-log/src"], "@deepseek-ai/dsh-client-ui-settings-subagent": ["./packages/client/ui-settings-subagent/src"], @@ -399,6 +402,7 @@ "@deepseek-ai/dsh-config-editor": ["./packages/boot/config-editor/src"], "@deepseek-ai/dsh-credentials": ["./packages/credentials/credentials/src"], "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], + "@deepseek-ai/dsh-cua-bundle": ["./packages/experimental/cua-bundle/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], @@ -442,6 +446,7 @@ "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"], "@deepseek-ai/dsh-persona": ["./packages/preset/persona/src"], "@deepseek-ai/dsh-plan-mode": ["./packages/plan/plan-mode/src"], + "@deepseek-ai/dsh-plugin-detail": ["./packages/util/plugin-detail/src"], "@deepseek-ai/dsh-plugin-market": ["./packages/boot/plugin-market/src"], "@deepseek-ai/dsh-plugin-market-index": ["./packages/util/plugin-market-index/src"], "@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b2f0423c7c..c335a29e83 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -131,6 +131,7 @@ { "path": "./packages/client/ui-settings-plugins" }, { "path": "./packages/client/ui-settings-shell" }, { "path": "./packages/client/ui-settings-agent-loop" }, + { "path": "./packages/experimental/cua/tsconfig.client.json" }, { "path": "./packages/client/ui-settings-session-log" }, { "path": "./packages/client/ui-settings-subagent" }, { "path": "./packages/client/ui-settings-web-search" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index fd740f29a1..b07df109db 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -74,6 +74,8 @@ "apps/web/tests/plugin-config.e2e.ts", "apps/web/tests/input-plugin-extensions.e2e.ts", "apps/web/tests/plugin-manager.e2e.ts", + "apps/web/tests/cua-optin.e2e.ts", + "apps/web/tests/cua-permissions.e2e.ts", "apps/web/tests/schedule-header.e2e.ts", "apps/web/tests/worktree-opt-in.e2e.ts", "apps/web/tests/session-inspector.e2e.ts", @@ -275,7 +277,8 @@ { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" }, - { "path": "./packages/experimental/cua" }, + { "path": "./packages/experimental/cua/tsconfig.host.json" }, + { "path": "./packages/experimental/cua-bundle" }, { "path": "./packages/experimental/cua-macos" }, { "path": "./packages/client/ui-sidebar-documentpreview/tsconfig.host.json" }, { "path": "./packages/client/ui-sidebar-browser/tsconfig.host.json" }, @@ -298,6 +301,7 @@ { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, { "path": "./packages/util/package-manifest" }, + { "path": "./packages/util/plugin-detail" }, { "path": "./packages/util/plugin-market-index" }, { "path": "./packages/util/http-proxy" }, { "path": "./packages/util/launch-environment" }, diff --git a/vitest.config.ts b/vitest.config.ts index 9c717c0ced..56d206808f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -331,6 +331,9 @@ export default defineConfig({ // The speech entry also imports generated Remote definitions; voice-input.e2e.ts // exercises the built entry, while source tests cover mountVoiceInput. 'packages/experimental/client-ui-voice-input/src/client/index.ts', + // The permission entry binds generated Remote code; cua-permissions.e2e.ts covers + // its built entry, while source tests exercise mountCuaPermissions and its UI. + 'packages/experimental/cua/src/client/index.ts', // The mods band entry imports the bridge's generated Remote contribution, which exists only in lib; // the Web snapshot exercises the built entry, while source tests cover mountModsBand. 'packages/experimental/client-ui-claude-code-mods/src/client/index.ts',