diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 945501552c..0cd1f0b744 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -36,8 +36,8 @@ en: 4479d41466723100 zh: 3a7f398e0d0070a5 /agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry: - en: 75e977fe0fb71d59 - zh: 00760c79da173cb7 + en: 91033aab32e4d8eb + zh: a11ada70e047af55 /agent-note-typert-gateway-targeted-method-calls/strict-consumer-api-types: en: 983f55c0ed4a8edc zh: 5a736e00e4b57e6a diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b11aa25668..383f9ef9fa 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -232,7 +232,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' This import brings the `.d.ts` map augmentation into the current TypeScript project while supplying the JS descriptor for the same contract as a value to the runtime. A business package that is not imported does not extend the current project's Remote API types. -The business package's published files must include `lib/typert.remote-client.d.ts.map`. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it. That target is a development-time path: a workspace consumer resolves it through the package link, so the published payload keeps excluding `src` and a published map simply resolves nothing. +The business package's published files must include `lib/typert.remote-client.d.ts.map`. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it. That target is a relative path inside the same package, and the payload publishes `src` beside the declaration map, so the navigation resolves for an installed consumer ([rationale](../process/2026-10-08-published-source-and-docs.md)). Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index a287402746..4c8530a314 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -232,7 +232,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' 该 import 让 `.d.ts` 的 map augmentation 进入当前 TypeScript project,同时把同一约定的 JS descriptor 作为值交给运行时。未 import 的业务包不会扩展当前 project 的 Remote API 类型。 -业务 package 的发布文件必须包含 `lib/typert.remote-client.d.ts.map`。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责发布它。该目标是开发期路径:workspace 消费者经 package link 解析它,因此发布产物仍然不含 `src`,已发布的 map 只是解析不到东西。 +业务 package 的发布文件必须包含 `lib/typert.remote-client.d.ts.map`。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责发布它。该目标是同一 package 内的相对路径,发布产物在声明映射旁一并发布 `src`,因此已安装的消费方也能完成这次导航([理由](../process/2026-10-08-published-source-and-docs.zh.md))。 仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index ad94eb0bd5..3112e2938d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -21,8 +21,8 @@ en: 29def175ab33b7d6 zh: 87e55ec28c20c7c1 /agent-note-package-and-update-the-electron-desktop-application/updates-and-recovery: - en: 4cb7014acfe71762 - zh: 2178e0337cba0324 + en: 61a02870beb953ec + zh: 205ecce330959e9a /agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy: en: 5faaa53b58121311 zh: e1c3fc8d21a27d1a diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 0241afef65..5821faf5a6 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -73,7 +73,7 @@ Electron update uses one `electron-updater` release stream and signed `electron- The [immediate-window reference](../../../../apps/desktop/README.md) owns the local loading page, direct Host startup, and recovery in the main window. Profile reconciliation follows the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, channel-selected metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. The selected channel determines the metadata name: `stable.yml` or `nightly.yml`, with `-mac` before the extension for macOS; version suffixes do not select the channel. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/publishing/README.md#upload-updates) defines generation and reuse. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index f3075a175d..50cf872de0 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -73,7 +73,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b [立即显示窗口参考](../../../../apps/desktop/README.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。 -`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 +`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据所选渠道确定的元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。所选渠道决定元数据名称:`stable.yml` 或 `nightly.yml`,macOS 在扩展名前加 `-mac`;版本后缀不决定渠道。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/publishing/README.zh.md#upload-updates)定义生成与复用方式。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml index 97e7927b58..09305c1c93 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml @@ -15,11 +15,11 @@ en: 62284bd7dd804188 zh: b3a8dfebffc01bdb /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition: - en: 8c6d355d91ec83d2 - zh: fcc74d1e6ef477bf + en: 8cd78e21602f17f0 + zh: 3843fea07e9b93a3 /agent-note-adjacent-agents-share-one-steer-send-message-operation/alternatives-considered: en: 7386e88e7051dce7 zh: dcea5b9e30374be3 /agent-note-adjacent-agents-share-one-steer-send-message-operation/consequences: - en: 9c23dd6ace029ec3 - zh: f08ef3f142534843 + en: 22a3af4c51aee23e + zh: 9d59315313d0f761 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md index a4cfbc91cb..080f10f19a 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -40,7 +40,7 @@ The globally registered model tool is direction-neutral and has one fixed schema ```ts interface SendMessageInput { - readonly agent_id: string + readonly agent: string readonly message: string } ``` @@ -53,7 +53,7 @@ Human browser prompts are not model-authored Agent messages. The remote prompt p ### Complete removal and reintroduction condition -The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that a tree member’s `agent_id` and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. +The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that a tree member’s `agent` reference and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. ## Alternatives considered @@ -71,7 +71,7 @@ The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, ## Consequences -- Model consumers expose one `send_message({ agent_id, message })` definition to parents and children, with no model-selected Queue versus Steer parameter. +- Model consumers expose one `send_message({ agent, message })` definition to parents and children, with no model-selected Queue versus Steer parameter. - The continuation manager remains the sole owner of tree authorization, residency, cold resume, waking admission, and teardown races. - Accepted messages may extend a running target's current turn; messages waiting together share next-step FIFO ordering. - Caller cancellation owns work only until inbox acceptance and does not retract an accepted message or dispose the target. diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md index d907eaeac0..2c944b44ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -40,7 +40,7 @@ interface AgentMessageSource { ```ts interface SendMessageInput { - readonly agent_id: string + readonly agent: string readonly message: string } ``` @@ -53,7 +53,7 @@ parent 与 child 以相同注册表顺序继承相同定义。标准定义携带 ### 完整移除与重新引入条件 -独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要树成员的 `agent_id` 与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 +独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要树成员的 `agent` 引用与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 ## 考虑过的替代方案 @@ -71,7 +71,7 @@ parent 与 child 以相同注册表顺序继承相同定义。标准定义携带 ## 后果 -- 模型 Consumer 向 parent 与 child 公开一个 `send_message({ agent_id, message })` 定义,不提供模型选择的 Queue 与 Steer 参数。 +- 模型 Consumer 向 parent 与 child 公开一个 `send_message({ agent, message })` 定义,不提供模型选择的 Queue 与 Steer 参数。 - 继续执行管理器仍是整树授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 - 被接受的消息可以延长运行中目标的当前轮次;一起等待的消息共享 next-step FIFO 顺序。 - 调用方取消只在 inbox 接受前掌管工作,不会撤回已接受消息或 dispose(资源释放)目标。 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml index 33ebd75e3d..c922f333d8 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 5a991fec31578cea zh: 13b75aa36eb22907 /agent-note-parent-owned-subagent-catalog-events/decision: - en: 4319c502038ab34d - zh: b11825ff9d744822 + en: d2290d8e70d42054 + zh: 64a2d1d0915f6c3e /agent-note-parent-owned-subagent-catalog-events/alternatives-considered: en: 7a3d4b9f69c243d8 zh: 82b03d16ccc94dd2 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md index 2fac858789..d5a03d7f9f 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -14,7 +14,7 @@ The child descriptor remains necessary for recovery and composition, but it cann The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. -The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Every local child enters the catalog independently of its result recipient; external children enter only with parent delivery. Workflow membership records describe execution progress and do not replace local Session discovery. Each event contains `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Local children use `continuable`; external executions use `external` and cannot be opened as local Sessions. Historical `one-shot` and `unknown` entries remain readable. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog. +The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Every local child enters the catalog independently of its result recipient; external children enter only with parent delivery. Workflow membership records describe execution progress and do not replace local Session discovery. Each event contains `childId`, `childCreatedAt`, mode, the mode-discriminated label, and, from payload v3, the child's [name](2026-11-02-subagent-catalog-names.md). Local children use `continuable`; external executions use `external` and cannot be opened as local Sessions. Historical `one-shot` and `unknown` entries remain readable. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog. Creation publishes only successful facts. A local activation admits the initial prompt before appending its catalog event. An external activation appends its event after provider startup and before returning the receipt. Failed admission or publication releases the unpublished execution; there is no compensating catalog event or rollback protocol. @@ -22,7 +22,7 @@ The child header and `subagent/descriptor` remain authoritative for recovery and The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points. -The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 6 accepts external membership, so the projection registry rebuilds incompatible caches from Session events. Payload v0 and v1 remain unchanged; v2 records external executions. +The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 7 accepts external membership and child names, so the projection registry rebuilds incompatible caches from Session events. Payload v0 and v1 remain unchanged; v2 records external executions; v3 adds names to continuable and external entries. Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding. diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md index 10088c52a7..7ebab0cef7 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -14,7 +14,7 @@ child descriptor 对恢复与 composition 仍然必要,但它不能作为 disc 共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 -父级 Session 的必读 `subagent/catalog` 事件是发现直接子级的持久化权威。每个本地子级都会进入目录,与结果接收方无关;外部子级仅在 parent 投递时进入目录。工作流成员记录描述执行进度,不替代本地 Session 发现。每个事件包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。本地子级使用 `continuable`;外部执行使用 `external`,不能作为本地 Session 打开。历史 `one-shot` 与 `unknown` 条目仍可读取。无效的自身事实(包括不支持的载荷版本)会使投影恢复失败,因为静默丢弃必读事实将返回不完整的 catalog。 +父级 Session 的必读 `subagent/catalog` 事件是发现直接子级的持久化权威。每个本地子级都会进入目录,与结果接收方无关;外部子级仅在 parent 投递时进入目录。工作流成员记录描述执行进度,不替代本地 Session 发现。每个事件包含 `childId`、`childCreatedAt`、mode、按 mode 区分的 label,以及从载荷 v3 起的子级[名称](2026-11-02-subagent-catalog-names.zh.md)。本地子级使用 `continuable`;外部执行使用 `external`,不能作为本地 Session 打开。历史 `one-shot` 与 `unknown` 条目仍可读取。无效的自身事实(包括不支持的载荷版本)会使投影恢复失败,因为静默丢弃必读事实将返回不完整的 catalog。 创建只发布成功事实。本地 activation 先接纳初始提示词,再追加 catalog 事件。外部 activation 在提供方启动后、返回回执前追加事件。准入或发布失败会释放未发布的执行;不存在补偿 catalog 事件或回滚协议。 @@ -22,7 +22,7 @@ child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威 注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。 -工具库拥有 chunk 布局与共享容量常量;catalog 拥有事件校验、fork 过滤和行转换。catalog 投影状态版本 6 接受外部成员,因此投影注册表会从 Session 事件重建不兼容缓存。载荷 v0 与 v1 保持不变;v2 记录外部执行。 +工具库拥有 chunk 布局与共享容量常量;catalog 拥有事件校验、fork 过滤和行转换。catalog 投影状态版本 7 接受外部成员和子级名称,因此投影注册表会从 Session 事件重建不兼容缓存。载荷 v0 与 v1 保持不变;v2 记录外部执行;v3 为可继续和外部条目增加名称。 fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml index 175c7d9ac3..1b8231e9ab 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml @@ -9,8 +9,8 @@ en: 2fc4ac7bdd4a3474 zh: 21ebdd3ac9264132 /agent-note-independent-libreoffice-kit-ownership/decision: - en: 0d8e05bcb940f88f - zh: b5b55f4ee82cf6a3 + en: 2ecc1fd680d8ec31 + zh: 1f5edeead8dceb44 /agent-note-independent-libreoffice-kit-ownership/alternatives-considered: en: 55175bb30630bf0c zh: 788853ddd5f7a4e9 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md index 50e4ab6e0c..efaf8848ea 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -10,11 +10,11 @@ LibreOffice compilation, source patches, platform qualification, and large binar ## Decision -The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `OfficeToPdf` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. +The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `OfficeKit` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. Kit releases run independently of Harness releases. The kit repository qualifies and publishes the Node API and engine npm packages at a shared version, starting at `0.0.1`. Harness consumes an exact npm version and commits its dependency resolution in `pnpm-lock.yaml`; Harness releases neither build nor publish kit packages. -The upstream API declares platform engines as optional dependencies. The [platform engine reference](../../../../packages/document/office-to-pdf/README.md) supersedes the original required-WASM fallback policy. Desktop installs the kit as an external npm dependency. Python sidecars keep the Worker, selected engine, and their dependency closure on the real filesystem, outside the executable’s virtual filesystem. Conversion requires neither downloads nor GitHub credentials. +The upstream API declares platform engines as optional dependencies. The [platform engine reference](../../../../packages/document/office-kit/README.md) supersedes the original required-WASM fallback policy. Desktop installs the kit as an external npm dependency. Python sidecars keep the Worker, selected engine, and their dependency closure on the real filesystem, outside the executable’s virtual filesystem. Conversion requires neither downloads nor GitHub credentials. The kit repository owns engine qualification and corresponding source materials. MPL-2.0 declarations, source availability, and redistribution notices accompany the API and engines; Harness retains these materials when packaging them. The notices check accepts the exact API, WASM, macOS ARM64/x64, and Windows ARM64/x64 package identities only at MPL-2.0; unrelated packages and changed license terms still reject. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md index 222197ab5b..ffe59ccce7 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -10,11 +10,11 @@ LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布 ## Decision -`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `OfficeToPdf` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `OfficeKit` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 -上游 API 将平台引擎声明为可选依赖。[平台引擎参考](../../../../packages/document/office-to-pdf/README.zh.md)取代最初强制携带 WASM 回退引擎的策略。Desktop 将 kit 作为外部 npm 依赖安装。Python sidecar 将 Worker、所选引擎及其依赖闭包保留在真实文件系统中,位于可执行文件的虚拟文件系统之外。转换无需下载或 GitHub 凭据。 +上游 API 将平台引擎声明为可选依赖。[平台引擎参考](../../../../packages/document/office-kit/README.zh.md)取代最初强制携带 WASM 回退引擎的策略。Desktop 将 kit 作为外部 npm 依赖安装。Python sidecar 将 Worker、所选引擎及其依赖闭包保留在真实文件系统中,位于可执行文件的虚拟文件系统之外。转换无需下载或 GitHub 凭据。 kit 仓库负责引擎资格验证和对应源码材料。API 和引擎携带 MPL-2.0 声明、可访问源码及再分发声明;Harness 打包时保留这些材料。再分发声明检查仅在许可为 MPL-2.0 时接受 API、WASM、macOS ARM64/x64 和 Windows ARM64/x64 的精确包标识;无关包和改变后的许可条款仍被拒绝。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml index a6d52473fa..432b55c141 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml @@ -9,11 +9,11 @@ en: b305613daff5a763 zh: 1aba13289664f65c /agent-note-bounded-shared-office-conversion/decision: - en: bcd0780f95f173bf - zh: 2a7f49db2575c59d + en: d3c8181e29c1ff3b + zh: a6f15b85e58907e5 /agent-note-bounded-shared-office-conversion/alternatives-considered: - en: 9d5cfb7fa5f66bd6 - zh: 4f4133daa2f02f43 + en: 73888970b0fc7f6b + zh: 9bcccf774116f849 /agent-note-bounded-shared-office-conversion/consequences: en: c2800ff56c8f89d1 zh: 3b3710072b59c443 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md index b15aa5421c..109469ba30 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -10,9 +10,9 @@ Office preview and explicit document inspection can request the same conversion. ## Decision -The `office-to-pdf` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. +The `office-kit` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. -The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse. +The [Host provider](../../../../packages/document/office-kit/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse. A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. @@ -28,7 +28,7 @@ Foreground preview and explicit QA requests precede background work. Disabling b **Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. -**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. +**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-kit` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md index f23c380967..45c5f7811a 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -10,9 +10,9 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 ## 决策 -`office-to-pdf` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 +`office-kit` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 -[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 +[宿主提供方](../../../../packages/document/office-kit/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 @@ -28,7 +28,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 **另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 -**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 +**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-kit` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml index 01cda717dd..2a32b20072 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml @@ -9,11 +9,11 @@ en: a9984a594cdac98d zh: 38768e7699bc8d39 /agent-note-progressive-office-skills-with-ordinary-shell-execution/decision: - en: d057c558d6b9803c - zh: acf3fdbc4cd7cee3 + en: 391c5d75994ab7e7 + zh: f820f407eb2d8769 /agent-note-progressive-office-skills-with-ordinary-shell-execution/alternatives-considered: - en: 8bca955255e6d982 - zh: bc313a4bcab3f6f9 + en: 0689e709dc1ff48d + zh: 5d2108ccdd13f093 /agent-note-progressive-office-skills-with-ordinary-shell-execution/consequences: en: cd4ff2423b2f5f9e zh: 63135aeae9c46415 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md index a89b896fe6..2b0d0a9db9 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md @@ -11,17 +11,17 @@ Combined authoring and rendering instructions spend context on operations unrela ## Decision -The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and six format-specific authoring or operation skills. The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. SDK and Desktop own their Office composition separately; ordinary Web and the shared base do not mount these plugins. +The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and three format-specific authoring skills; the operation skills this decision introduced are replaced by the [on-demand Office tools](2026-10-31-native-office-tool.md). The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. SDK and Desktop own their Office composition separately; ordinary Web and the shared base do not mount these plugins. [Office skills in every deployment](2026-10-26-cross-deployment-office-kit.md) supersedes this mounting decision, and the [on-demand Office tools](2026-10-31-native-office-tool.md) supersede the shell execution and renderer-tool rejection. Carriers own interpreter and resource locations. Operation skills append the configured Node and CLI paths only when their bodies are loaded; authoring skills use the selected Python and shared checker resource. Loading instructions does not prepare engines or authoring payloads. Ordinary shell execution retains its existing permissions and process management. -Office dependency ranges allow patch updates; the root lock records the resolved adapter and engine versions. +LibreOffice Kit dependencies declare one exact version in every manifest; the root lock records the resolved adapter and engine versions. ## Alternatives considered **Keep a dedicated renderer tool.** A small typed schema constrains rendering arguments, but requires its own execution configuration, scoped registration, projection, and activation lifecycle. The CLI operation skills accept shell quoting and model-selected arguments in exchange for reusing the existing approval, sandbox, cancellation, and output mechanisms. -**Hide subskills from the catalog.** This requires a generic visibility policy independent of invocation permission. Listing seven short summaries preserves the existing skill API while full instruction bodies remain demand-loaded. +**Hide subskills from the catalog.** This requires a generic visibility policy independent of invocation permission. Listing four short summaries preserves the existing skill API while full instruction bodies remain demand-loaded. **Automatically inject guides for file extensions.** Extensions do not distinguish text edits, recalculation, conversion, and requested visual checks. Model-selected routing keeps unrelated operations out of context and permits explicit direct invocation. diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md index 0aeb95eb1d..32a752f09c 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md @@ -11,17 +11,17 @@ Status: implemented ## 决策 -[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和六个各格式创作或操作技能。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。SDK 和 Desktop 分别拥有 Office 组合;普通 Web 和共享 base 不挂载这些插件。 +[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和三个各格式创作技能;本决策引入的操作技能已由[按需激活的 Office 工具](2026-10-31-native-office-tool.zh.md)取代。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。SDK 和 Desktop 分别拥有 Office 组合;普通 Web 和共享 base 不挂载这些插件。[Office 技能在所有部署中可用](2026-10-26-cross-deployment-office-kit.zh.md)取代此挂载决策,[按需激活的 Office 工具](2026-10-31-native-office-tool.zh.md)取代其 shell 执行方式和对渲染工具的否决。 载体负责解释器和资源位置。操作技能仅在加载正文时附加配置的 Node 与 CLI 路径;创作技能使用选定的 Python 和共享检查器资源。加载说明不准备引擎或创作 payload。普通 shell 执行保留现有权限和进程管理。 -Office 依赖范围允许补丁版本更新;根锁文件记录实际解析的适配器与引擎版本。 +LibreOffice Kit 依赖在每个清单中声明同一个精确版本;根锁文件记录实际解析的适配器与引擎版本。 ## 考虑过的替代方案 **保留专属渲染工具。** 小型类型化 schema 能约束渲染参数,但需要专属执行配置、作用域注册、投影和激活生命周期。CLI 操作技能接受 shell 引用和模型选择参数的成本,以复用现有审批、沙箱、取消及输出机制。 -**从目录隐藏子技能。** 这需要独立于调用权限的通用可见性策略。列出七条简短摘要可保留现有技能 API,完整说明仍按需加载。 +**从目录隐藏子技能。** 这需要独立于调用权限的通用可见性策略。列出四条简短摘要可保留现有技能 API,完整说明仍按需加载。 **按文件扩展名自动注入指南。** 扩展名无法区分文本编辑、重计算、转换和用户要求的视觉检查。模型选路使无关操作不进入上下文,并允许显式直接调用。 diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml index 262e03bb22..ff9a3301b2 100644 --- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml @@ -15,5 +15,5 @@ en: 10bebc7f982cf7f4 zh: 9247a2ba57921a11 /agent-note-retained-ptc-processes-follow-file-policy/consequences: - en: b3ddf273158d3754 - zh: 916356f4074b3622 + en: 1f94ca28e0cb7564 + zh: d8a918c0c5dfc6d8 diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md index 2c4235fcc3..21c3d59188 100644 --- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md @@ -28,4 +28,4 @@ An approved one-time escalation omits the state owner and receives a fresh tempo ## Consequences -Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before supplying a different policy without a Session mode change; the provider rejects inconsistent reuse. The sandbox inherits the existing backends’ file-effect coverage and platform limitations. +Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before changing its mode, workspace root or Session identity; the provider rejects inconsistent reuse. [Directory-effect lifetimes](2026-10-26-session-writable-directory-effects.md) permit extra-directory changes while retained processes keep their launch grants. The sandbox inherits the existing backends’ file-effect coverage and platform limitations. diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md index d841702941..6403ed5ef1 100644 --- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md @@ -28,4 +28,4 @@ Status: implemented ## 影响 -普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。未发生 Session 模式变化时,运行时消费者在传入不同策略前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。沙箱继承现有后端的文件约束范围与平台限制。 +普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。运行时消费者在更改模式、工作区根目录或 Session 身份前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。[目录 effect 生命周期](2026-10-26-session-writable-directory-effects.zh.md)允许额外目录发生变化,同时保留进程继续使用启动时的授权。沙箱继承现有后端的文件约束范围与平台限制。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml index f1c602359c..61d408cf40 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md /agent-note-subagent-identity-in-required-runtime-context: - en: ce8f378b6e7b57fa - zh: 26bd0415b4dba94a + en: 3da6e33c5dc2c917 + zh: 9c8d4542cda63fc4 /agent-note-subagent-identity-in-required-runtime-context/problem: en: f944254565541454 zh: 27fa9f67c671dec7 diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md index fb8e92e811..bb2405a145 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-10-13-team-identity-runtime-context.zh.md) +[Catalog names](2026-11-02-subagent-catalog-names.md) supersede the Session-id identity text below: agents see their own, direct parent, and root names. The runtime-context retention decision remains active. + ## Problem A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect the current subagent tree while keeping shared system instructions uniform. diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md index f3ebb3e6b3..09adf8c130 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-10-13-team-identity-runtime-context.md) | 中文 +[目录名称](2026-11-02-subagent-catalog-names.zh.md)取代下文以 Session id 表述身份的文本:agent 看到自身、直接父级和根的名称。runtime context 保留决策仍然有效。 + ## Problem 嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 subagent 树,同时保持共享系统指令一致。 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 index 22e7100c04..1d72bf34af 100644 --- 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 @@ -2,21 +2,21 @@ # 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 +/agent-note-registry-and-git-bundle-updates: + en: 970e28efc1c454c5 + zh: 397ea3ace1d5b172 +/agent-note-registry-and-git-bundle-updates/problem: + en: b1cf4a8c1135321c + zh: e60ab2a2d876fc72 +/agent-note-registry-and-git-bundle-updates/decision: + en: 29e891007e8efe08 + zh: ac038ad0c2e17618 +/agent-note-registry-and-git-bundle-updates/alternatives-considered: + en: ba19c772e06f1f7d + zh: 65d18f2d89ac3adc +/agent-note-registry-and-git-bundle-updates/consequences: + en: aa716f92ddbdd547 + zh: 01c29b7f21db33b4 +/agent-note-registry-and-git-bundle-updates/verification: + en: b0ac6f7faff0f5d5 + zh: 5bbc3d5c16d4f2f1 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 index e17647a7db..7257cbf149 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md +++ b/.agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md @@ -1,4 +1,4 @@ -# Agent Note: Registry bundle updates +# Agent Note: Registry and Git bundle updates Status: implemented @@ -6,15 +6,17 @@ 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. +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. A bundle installed from Git had no update path either: a Git repository publishes no version list, and installing a newer commit from a plugin index over the installed copy resolves the package to another directory, which HMR cannot replace live, so the change waited for a restart and nothing reinstalled the old commit when it failed. ## 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. +**The Host owns the update and its recovery.** For a registry copy, `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, expected })`, where `expected` makes the installation undo itself, modules included, unless it installs `name` at `version`; 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. +**Eligibility is a listed fact.** `BundleInfo.update` is `registry` for a third-party profile dependency saved as a registry spec under its own name and not as an `npm:` alias, and `git` for one saved as a Git spec pinned to a full commit, whose reinstallation brings back the removed version; either needs a semver version and no plugin management. The page shows the Versions section only for `registry` bundles, `listVersions` refuses every other bundle, and `updateBundle` refuses every bundle without `update`. The `plugin_manager` tool, which has no update action, omits the field. + +**A Git copy updates to a version a plugin index lists, from the repository directory it came from.** A Git repository has no version list, while a plugin index pins each Git version to a commit and a package directory. The `pluginMarket` Host service marks a version with `update` when it is newer than a `git` copy and pins a commit of the same repository and `path:` directory; Discover offers **Update** for it and hands the package, version, and spec to the Plugins page, whose update dialog calls `updateBundle(name, version, { spec })`. The Host requires such a spec for a `git` copy and refuses one for a `registry` copy; `pinnedGitOrigin`, which both services import from `@deepseek-ai/dsh-plugin-manager`, compares origins regardless of the form pnpm saved, because a profile saves the SSH form of an HTTPS repository it cannot read and the `github:` shortcut of a GitHub URL. pnpm reads a Git package only while installing it, so before the removal the Host checks only that the network reaches the target's repository host; an unreachable host would fail the target and the reinstallation alike. A check Git cannot run, or one refused for credentials, is left to pnpm, which installs a public hosted commit as a tarball without Git. The rest is checked during the installation: compatibility as for every installation, and `expected` undoes a target that installs another package or version, or no bundle, before its bundle loads, failing with `unexpected-package` or `not-bundle`. The removed copy is reinstalled from the spec the profile saved, and Discover never installs over an installed copy. **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. @@ -28,13 +30,26 @@ Third-party bundles installed from a registry had no update path. The plugin pag **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. +**Read Git versions from repository tags in the Host.** A tag names no package directory in a monorepo and says nothing about the version an index vetted; the plugin index already pins commit, directory, and version. + +**Install the newer commit over the installed copy.** It needs no removal, but with HMR the package resolves to another directory, so the live replacement is refused until a restart, and a failed installation reinstalls nothing. + +**Check the package name before removing the installed copy.** pnpm reads a Git spec's manifest only by fetching it, and a second fetch before the installation would double the download; checking the installed package inside the run keeps one fetch, and the reinstallation recovers a wrong target. + +**Remove a target that installed another package after the installation.** Its bundle would already have loaded and run, and when the other package was an existing dependency, `pnpm add` would have replaced it, so removing it would uninstall a plugin the person kept. Undoing the run with its lockfile restores that dependency too. + +**Accept a target from another repository.** Index content is unsigned, and the update dialog carries no trust notice of its own, so any configured index could replace a plugin's code with code from a source the person never chose; a plugin that moved to another repository is uninstalled and installed again, with the install confirmation. + ## 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. +- A registry 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. +- A Git target whose commit is missing, that declares no bundle, rejects the running DSH, or installs another package or version is found only after the installed copy was removed; the reinstallation recovers it at the cost of a stop and a second installation. +- A Git copy saved with credentials in its URL is reinstalled from the saved spec without them, so a private repository needs a Git credential helper. +- A Git copy saved with a branch, a tag, or a range, or one whose plugin moved to another repository, updates only by uninstalling and installing again. ## Verification @@ -43,5 +58,11 @@ Third-party bundles installed from a registry had no update path. The plugin pag | 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` | +| Git eligibility, update through a commit of the same directory, reinstallation from the saved spec, a target that installs another package or version, the no-HMR path with a target without a bundle, an unreachable repository host, and spec refusals | `packages/boot/plugin-manager/tests/manager.spec.ts` | +| Git origin across the forms pnpm saves | `packages/boot/plugin-manager/tests/install-spec.spec.ts` | +| A run undone with its modules when it installs another package, another version, or no bundle where one is expected | `packages/boot/plugin-manager/tests/operations.spec.ts` | +| The connection check of a Git repository on any host | `packages/boot/plugin-manager/tests/github-connection.spec.ts` | +| Git versions of the installed copy's directory marked as updates in an index listing | `packages/boot/plugin-market/tests/market.spec.ts` | +| Version checks, update state, and step progress in the store, including a Git update another tab names | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` | +| Versions section, toolbar check, every update outcome, and a Git target's repository in the dialog | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` | +| Discover's **Update** on cards and version rows, and no installation over an installed copy | `packages/client/ui-plugin-market/tests/discover.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 index b9a680ed3b..b308d27e39 100644 --- 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 @@ -1,4 +1,4 @@ -# Agent Note: 注册表组合包更新 +# Agent Note: 注册表与 Git 组合包更新 Status: implemented @@ -6,15 +6,17 @@ Status: implemented ## 问题 -从注册表安装的第三方组合包没有更新途径。插件页只为官方组合包提供 **更新**,其目标是当前运行的 DSH 版本,因此用户只能在 DSH 之外得知第三方新版本,并通过卸载再安装来替换。有 HMR 时,安装到已安装的包之上会被拒绝;手动移除后安装失败,插件就处于未安装状态。 +从注册表安装的第三方组合包没有更新途径。插件页只为官方组合包提供 **更新**,其目标是当前运行的 DSH 版本,因此用户只能在 DSH 之外得知第三方新版本,并通过卸载再安装来替换。有 HMR 时,安装到已安装的包之上会被拒绝;手动移除后安装失败,插件就处于未安装状态。从 Git 安装的组合包同样没有更新途径:Git 仓库不发布版本列表;从插件索引把较新的提交安装到已安装副本之上时,包会解析到另一个目录,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` 通告每一步,供对话框的进度列表使用。 +**更新及其恢复由 Host 负责。** 对注册表副本,`updateBundle` 先查询目标版本,在任何改动之前拒绝不存在的版本、未声明组合包的版本,以及 `engines.dsh` 或 DSH peer 拒绝当前运行版本的版本。有 HMR 时,它随后调用 `removeBundle` 与 `installBundle(name@version, { saveExact, registry, enabled, expected })`,其中 `expected` 让安装在装入的不是 `version` 版本的 `name` 时连同模块一起撤销自身;该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从被移除版本原来的注册表重新安装它,没有记录时按配置的计划安装。重新安装不带请求 id,因此取消更新无法中止它。没有 HMR 时,它直接安装在运行中的版本之上,`installBundle` 在这种情况下允许这样做并报告 `restart-required`。`plugin-manager/update-state` 通告每一步,供对话框的进度列表使用。 -**更新资格是列出的事实。** `BundleInfo.updatable` 标记以自身包名、按注册表 spec 保存(不是 `npm:` 别名)、带 semver 版本且不承载插件管理的第三方 profile 依赖;页面只为这些组合包显示版本区块,两个方法都拒绝其他组合包。没有更新操作的 `plugin_manager` 工具省略该字段。 +**更新资格是列出的事实。** 以自身包名、按注册表 spec 保存(不是 `npm:` 别名)的第三方 profile 依赖,其 `BundleInfo.update` 为 `registry`;按固定到完整提交的 Git spec 保存的为 `git`,其重新安装能恢复被移除的版本;两者都要求 semver 版本且不承载插件管理。页面只为 `registry` 组合包显示版本区块,`listVersions` 拒绝其他所有组合包,`updateBundle` 拒绝所有没有 `update` 的组合包。没有更新操作的 `plugin_manager` 工具省略该字段。 + +**Git 副本从其来源的仓库目录更新到插件索引列出的版本。** Git 仓库没有版本列表,而插件索引把每个 Git 版本固定到一个提交和一个包目录。版本比 `git` 副本新、且固定到同一仓库与 `path:` 目录中的一个提交时,`pluginMarket` Host 服务为其标上 `update`;「发现」为其提供 **更新**,并把包、版本和 spec 交给插件页,由插件页的更新对话框调用 `updateBundle(name, version, { spec })`。Host 要求 `git` 副本提供这样的 spec,并拒绝为 `registry` 副本提供的 spec;两个服务都从 `@deepseek-ai/dsh-plugin-manager` 导入 `pinnedGitOrigin`,它比较仓库来源时不考虑 pnpm 保存的形式,因为 profile 会为它无法读取的 HTTPS 仓库保存 SSH 形式,并为 GitHub URL 保存 `github:` 简写。pnpm 只在安装时读取 Git 包,因此 Host 在移除之前只检查网络能否到达目标仓库所在的主机;主机不可达时,目标的安装与重新安装同样会失败。Git 无法运行的检查,或因需要凭据而被拒绝的检查,交给 pnpm 处理,pnpm 无需 Git 即可将公开托管仓库中的提交作为 tarball 安装。其余检查在安装过程中进行:兼容性的检查与每次安装相同,`expected` 在组合包加载前撤销安装了另一个包或版本、或不含组合包的目标,并以 `unexpected-package` 或 `not-bundle` 失败。被移除的副本从 profile 保存的 spec 重新安装,「发现」从不在已安装副本之上安装。 **更新只向前。** 页面只列出比已安装版本新的版本,`updateBundle` 拒绝其他版本,因此更新不会让插件读取更新版本写入的数据。 @@ -28,13 +30,26 @@ Status: implemented **在读取组合包时列出版本。** `listBundles` 不联网;加入注册表查询会让打开插件页变成网络操作,且每个版本一个子进程。 +**由 Host 从仓库 tag 读取 Git 版本。** 在 monorepo 中,tag 不指明包目录,也不说明索引审核过哪个版本;插件索引已经固定了提交、目录与版本。 + +**把较新的提交安装到已安装副本之上。** 这样无需移除,但有 HMR 时包会解析到另一个目录,因此运行中替换会被拒绝,直到重启;安装失败时也不会重新安装任何版本。 + +**在移除已安装副本之前检查包名。** pnpm 只有拉取 Git spec 才能读取其 manifest(元数据清单),在安装前再拉取一次会使下载量翻倍;在本次运行中检查安装的包只需拉取一次,目标不对时由重新安装恢复。 + +**在安装之后移除安装了另一个包的目标。** 那时其组合包已经加载并运行;若另一个包是已有依赖,`pnpm add` 已将其替换,移除它会卸载用户保留的插件。连同锁文件撤销本次运行,也会恢复该依赖。 + +**接受来自其他仓库的目标。** 索引内容没有签名,更新对话框也不带自己的信任提示,因此任何已配置的索引都能用来自用户从未选择的来源的代码替换插件的代码;迁移到另一个仓库的插件需要先卸载再安装,并经过安装确认。 + ## 后果 - 检查一个包需要一次 `pnpm view`,再加上每个列出版本一次,每次都受 `inspectTimeoutMs` 限制。 -- 更新保存的目标版本,以及失败后重新安装的版本,都是确切版本,即使 profile 原先保存的是范围;有 HMR 时,组合包排到选中列表末尾。 +- 更新保存的注册表目标版本,以及失败后重新安装的版本,都是确切版本,即使 profile 原先保存的是范围;有 HMR 时,组合包排到选中列表末尾。 - 移除与安装分别获取 profile 锁,其他操作可能在两者之间运行;安装被拒绝(例如锁等待超时)时,同样会触发重新安装。 - 依赖需要新的构建脚本批准的版本会安装失败,并重新安装为原版本;其脚本通过普通安装批准。 - 重新安装也失败时,包处于未安装状态,对话框会如实说明。 +- 提交不存在、未声明组合包、拒绝当前运行 DSH,或安装了另一个包或版本的 Git 目标,只有在已安装副本被移除之后才会被发现;重新安装可以恢复,代价是插件停止一次并多安装一次。 +- URL 中带凭据保存的 Git 副本,从已保存的 spec 重新安装时不带这些凭据,因此私有仓库需要 Git 凭据助手。 +- 以分支、标签或范围保存的 Git 副本,或其插件已迁移到另一个仓库的 Git 副本,只能通过先卸载再安装来更新。 ## 验证 @@ -43,5 +58,11 @@ Status: implemented | 列表、注册表回退、拒绝、兼容性、预发布、弃用与版本上限 | `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` | +| Git 更新资格、经同一目录中某个提交的更新、从已保存 spec 重新安装、安装另一个包或版本的目标、目标不含组合包时的无 HMR 路径、不可达的仓库主机,以及对 spec 的拒绝 | `packages/boot/plugin-manager/tests/manager.spec.ts` | +| pnpm 保存的各种形式下的 Git 来源 | `packages/boot/plugin-manager/tests/install-spec.spec.ts` | +| 安装了另一个包、另一个版本,或缺少预期的组合包时,连同模块一起撤销的运行 | `packages/boot/plugin-manager/tests/operations.spec.ts` | +| 对任意主机上 Git 仓库的连接检查 | `packages/boot/plugin-manager/tests/github-connection.spec.ts` | +| 索引列表中被标为更新的、已安装副本所在目录的 Git 版本 | `packages/boot/plugin-market/tests/market.spec.ts` | +| store 中的版本检查、更新状态与步骤进度,包括另一个标签页指定的 Git 更新 | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` | +| 版本区块、工具栏检查、每种更新结果,以及对话框中 Git 目标的仓库 | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` | +| 「发现」卡片和版本行上的 **更新**,以及不在已安装副本之上安装 | `packages/client/ui-plugin-market/tests/discover.client.spec.tsx` | diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml index 282acab92a..bdf1744de7 100644 --- a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md /agent-note-agent-teams-teammates-are-subagents: - en: 51e2fc51a6e1622a - zh: 7e09ea6292e5921d + en: f49f9e7ef588e612 + zh: 7da804056221af51 /agent-note-agent-teams-teammates-are-subagents/problem: en: ca2848e3bbc3a009 zh: 0551e32ff6d187d0 diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md index 8cab7023e0..2b11e98b0e 100644 --- a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-10-15-agent-teams-as-subagents.zh.md) -The adjacency and parent-lifetime restrictions are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). [Task-board inbox coordination](2026-10-19-team-task-board-inbox.md) supersedes the separate roster and Team-owned wait; the single subagent identity decision remains active. +The adjacency and parent-lifetime restrictions are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). [Task-board inbox coordination](2026-10-19-team-task-board-inbox.md) supersedes the separate roster and Team-owned wait; the single subagent identity decision remains active. [Catalog names](2026-11-02-subagent-catalog-names.md) supersede the statement that creation accepts no member name and the rejection of unique names below. ## Problem diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md index 8c113d288a..a7467f1dbb 100644 --- a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-10-15-agent-teams-as-subagents.md) | 中文 -邻接和父级生命周期限制由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)取代。[任务板收件箱协调](2026-10-19-team-task-board-inbox.zh.md)取代独立名册和 Team 所有的等待;单一 subagent 身份决策仍有效。 +邻接和父级生命周期限制由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)取代。[任务板收件箱协调](2026-10-19-team-task-board-inbox.zh.md)取代独立名册和 Team 所有的等待;单一 subagent 身份决策仍有效。[目录名称](2026-11-02-subagent-catalog-names.zh.md)取代下文“创建不接收成员名称”的表述及对唯一名称的否决。 ## Problem diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml index ee52c30615..fd6879706d 100644 --- a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml @@ -9,8 +9,8 @@ en: c583c3cdb894e417 zh: 6edc0354b79f4f26 /agent-note-communication-follows-the-subagent-tree/decision: - en: da35a75e069fa1e4 - zh: 7edf62ad66ab04ec + en: 767b4b4dfd84f72e + zh: 36617ec0dab777ee /agent-note-communication-follows-the-subagent-tree/alternatives-considered: en: 470f9cf8c3e32af9 zh: 73bbe29767c5d02a diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md index 30dee6781a..8b80b0b841 100644 --- a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md @@ -10,13 +10,13 @@ A Team shares one task board across the root's entire subagent tree. Restricting ## Decision -`list_agents` starts at the caller's live root and lists that root and all reachable continuable descendants, including the caller. Parent ids and depth remain relative to the root. The existing parent catalogs remain authoritative; no Team directory is stored separately. +`list_agents` starts at the caller's live root and lists that root and all reachable continuable descendants, including the caller. Full names remain relative to the root. The existing parent catalogs remain authoritative; no Team directory is stored separately. `send_message` accepts another root or continuable member of the same tree. The sender must be the exact live root or a resident continuable subagent. Cross-branch delivery verifies the target descriptor and creation catalogs, then restores only the target under the Team root. Final inbox admission rechecks the sender and root. Cancellation or rejection before acceptance releases the new target instance; accepted input follows normal Agent persistence. The existing Agent registry records runtime ownership by the Team root. Creation parent ids and catalogs preserve ancestry for discovery and interrupt authority. A member finishing or being released affects only its own instance; Team-root shutdown joins all startup and cleanup. This removes ancestor restoration, parent retention, and recursive disposal without adding a Team runtime registry. Saved target presets and permission events supply restoration configuration. -This supersedes the adjacency, discovery, and parent-lifetime restrictions in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their shared inbox, scheduling, attribution, and Team reuse decisions remain active. Members use Session ids for addressing; ordinary Session forks lead independent Teams. +This supersedes the adjacency, discovery, and parent-lifetime restrictions in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their shared inbox, scheduling, attribution, and Team reuse decisions remain active. Members address one another by the names that [catalog names](2026-11-02-subagent-catalog-names.md) define, or by Session id; ordinary Session forks lead independent Teams. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md index 58d075e9a6..b026bda26c 100644 --- a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md @@ -10,13 +10,13 @@ Status: implemented ## Decision -`list_agents` 从调用方的在线根开始,列出该根与所有可达的可继续后代,包括调用方。父级 id 和深度始终相对根计算。现有父级目录仍是权威来源;不单独存储 Team 目录。 +`list_agents` 从调用方的在线根开始,列出该根与所有可达的可继续后代,包括调用方。完整名称始终相对根计算。现有父级目录仍是权威来源;不单独存储 Team 目录。 `send_message` 接受同一棵树中的其他根或可继续成员。发送者必须是确切在线根或驻留的可继续子代理。跨分支投递验证目标描述符和创建目录后,仅在 Team 根下恢复目标。最终收件箱准入重新检查发送者与根。接受前的取消或拒绝释放新建目标实例;已接受输入遵循普通 Agent 持久化。 现有 Agent 注册表记录 Team 根对运行时实例的所有权。创建父级 id 和目录保留用于发现及中断授权的祖先关系。成员完成或释放只影响自身实例;关闭 Team 根会等待全部启动和清理。这删除了祖先恢复、父级保留和递归释放,无需增加 Team 运行时注册表。目标保存的 preset 和权限事件提供恢复配置。 -本决策替代[相邻消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)与[子代理队友](2026-10-15-agent-teams-as-subagents.zh.md)中的邻接、发现及父子生命周期限制。它们的共享收件箱、调度、发送者归属和 Team 复用决策仍有效。成员使用 Session id 寻址;普通 Session 分叉领导独立 Team。 +本决策替代[相邻消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)与[子代理队友](2026-10-15-agent-teams-as-subagents.zh.md)中的邻接、发现及父子生命周期限制。它们的共享收件箱、调度、发送者归属和 Team 复用决策仍有效。成员使用[目录名称](2026-11-02-subagent-catalog-names.zh.md)定义的名称或 Session id 互相寻址;普通 Session 分叉领导独立 Team。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml index 81682a6981..90ff666645 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml @@ -9,8 +9,8 @@ en: ac391b569d2bc7e9 zh: a95f613aa2b330b8 /agent-note-subagent-tools-own-communication-guidance/decision: - en: 52fb33963bedfe07 - zh: 75fe66d80f47323f + en: d83310e85ffcb3cf + zh: eb1f069948399d93 /agent-note-subagent-tools-own-communication-guidance/alternatives-considered: en: c55d44c6414e0c4c zh: 9fa2ab31843e259b diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md index 2222cd5dd7..5aa5ddca3d 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md @@ -10,7 +10,7 @@ The continuation manager inspected a Symbol on the standard messaging tool befor ## Decision -`tool-subagent-control` owns messaging instructions and parent addressing. Its logged runtime context gives delegated children their direct creator’s id from the Session header, explains when to send findings, and states that sending does not finish a turn. The context is emitted only for delegated children when the plugin’s own tool definition is admitted in the current scope and step; roots and ordinary Session forks receive none. Communication guidance follows inherited history without changing the shared system prompt or tool schema. +`tool-subagent-control` owns messaging instructions and parent addressing. Its logged runtime context gives delegated children their direct creator’s name from `ctx.subagents.namesOf()`, explains when to send findings, and states that sending does not finish a turn. The context is emitted only for delegated children when the plugin’s own tool definition is admitted in the current scope and step; roots and ordinary Session forks receive none. Communication guidance follows inherited history without changing the shared system prompt or tool schema. The manager owns result delivery. Local activations with `delivery: 'parent'` receive automatic-result guidance even without messaging tools; caller-owned activations receive no parent-delivery promise. Settlement timing, recipients, output, and suppression of closing text after a final message to the parent remain unchanged. Tool visibility grants no messaging authority. diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md index 21242507b7..67f1146819 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`tool-subagent-control` 拥有通信说明和父级寻址。持久记录的运行时上下文从 Session header 读取委派子级的直接创建者 id,说明何时发送发现,并指出发送不会结束轮次。仅当本插件的工具定义在当前作用域和 step 中可用时,才向委派子级提供上下文;根代理和普通 Session fork 不接收。通信指导位于继承历史之后,不改变共享系统提示词或工具 schema。 +`tool-subagent-control` 拥有通信说明和父级寻址。持久记录的运行时上下文通过 `ctx.subagents.namesOf()` 提供委派子级的直接创建者名称,说明何时发送发现,并指出发送不会结束轮次。仅当本插件的工具定义在当前作用域和 step 中可用时,才向委派子级提供上下文;根代理和普通 Session fork 不接收。通信指导位于继承历史之后,不改变共享系统提示词或工具 schema。 管理器拥有结果交付。采用 `delivery: 'parent'` 的本地 activation 即使没有通信工具,也会收到自动结果交付指导;调用方拥有的 activation 不会收到父级交付承诺。结算时序、接收方、输出,以及最后向父级发送消息后省略结束文本的规则保持不变。工具可见性不授予通信权限。 diff --git a/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.i18n.yaml new file mode 100644 index 0000000000..af61aa5c77 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-26-cross-deployment-office-kit.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-26-cross-deployment-office-kit.md +/agent-note-office-skills-in-every-deployment-with-one-office-kit-package: + en: aef0b589f3562990 + zh: c2227dd19e282c6f +/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/problem: + en: 129d30508926bbc5 + zh: 15feff44556fc9b7 +/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/decision: + en: 7d70a22025ab858e + zh: cc37cb9932e78787 +/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/alternatives-considered: + en: 167c52da0171028f + zh: 9c87c2370d0719df +/agent-note-office-skills-in-every-deployment-with-one-office-kit-package/consequences: + en: ffd13aad24ca750c + zh: a22f513e123f31b4 diff --git a/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.md b/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.md new file mode 100644 index 0000000000..5cf444a5b1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.md @@ -0,0 +1,43 @@ +# Agent Note: Office skills in every deployment with one Office Kit package + +Status: implemented + +English | [中文](2026-10-26-cross-deployment-office-kit.zh.md) + +## Problem + +Office skills were mounted only by Desktop and the Python SDK, so ordinary `dsh web` users could not create or inspect Office documents with the same guidance. Each deployment located LibreOffice Kit differently: the conversion provider, the skills, and the Desktop shell each held their own lookup, and an absent Kit made the conversion row fail to load. A Python SDK executable without its downloaded Office sidecar, or a source checkout without native engines, is a deployment that lacks Kit, not a misconfigured composition; failing the row hid the provider and the skills even though most of their behavior did not need Kit. + +## Decision + +The [Web bundle](../../../../packages/bundle/web-app/cordis.patch.yml) and the [SDK bundle](../../../../packages/bundle/sdk-app/cordis.patch.yml) mount [`skill-office`](../../../../packages/skill/skill-office/README.md) in Desktop, ordinary `dsh web`, and the SDK. This supersedes the mounting decision of [progressive Office skills](2026-09-27-progressive-office-skills.md); its skill structure remains current, and the [on-demand Office tools](2026-10-31-native-office-tool.md) replace its shell execution. + +Every deployment reads the Office skills from the `skill-office` package's `assets/`, which hold only `SKILL.md` files. The Host reads them with ordinary file reads: Desktop's Electron Host reads them inside `app.asar`, and the SDK executable embeds them, so no carrier copies the skills to a separate resource directory. A composition selects another directory through `assetRoot`, or overrides individual skills with same-name filesystem skills. + +The bundled Python environment ([`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md)) mounts only in Desktop and in the SDK when a primary runtime is available. `skill-office` declares `pythonEnvironment: 'workspace-dependencies' | 'user'` explicitly; each bundle row selects the value for its deployment. The default is `'workspace-dependencies'` because older SDK patches replace the complete `config` of the `skill-office` row; such a patch omits the new key and keeps the bundled-environment instructions it was written for. + +[`@deepseek-ai/dsh-office-kit`](../../../../packages/document/office-kit/README.md) owns Kit location and the shared conversion service. Kit location is an internal implementation of the provider and is not exported: the provider passes its own `import.meta.resolve` lookup, and native engine paths use ASAR-unpacked mapping. Location reads no environment variables. The [on-demand Office tools](2026-10-31-native-office-tool.md) run Kit operations inside the Host, so no deployment publishes Kit CLI paths or Office executable variables. + +An absent Kit degrades instead of failing a load. The conversion service registers and rejects conversions with code `unavailable`, retaining only a successful Kit import and reporting each distinct failure once; the `office_render`, `office_convert`, and `office_recalculate` tools stay registered and return that message when called. + +`office-to-pdf` became `office-kit` so that later Kit consumers, such as native rendering (PR #5374) and the `tool-office` package, take Kit from one package. LibreOffice Kit dependencies declare one exact version in every manifest, so the converter and packaged engine come from the same Kit release. + +## Alternatives considered + +**Keep Office skills out of ordinary Web.** This keeps ordinary Web free of Office guidance it might not execute, but leaves Web users without document authoring that needs only Python libraries. Skills now direct the model to the user's Python when no bundled environment exists. + +**Default `pythonEnvironment` to `'user'`.** This matches ordinary Web, but an older SDK patch that replaces the row's `config` would silently switch Desktop-like SDK deployments to the user's Python. + +**Read deployment environment variables inside the locator.** This removes the `cordis.yml` expressions, but hides deployment configuration inside a library and makes source, Web, Desktop, and SDK behavior depend on ambient variables. Bundles already own deployment-specific expressions. + +**Fail the conversion row when Kit is absent.** This reports the absence at load, which the misconfiguration rule prefers, but the absence belongs to the deployment rather than the configuration, and it disables preview rendering paths and dependent skills that do not need Kit. + +**Keep a separate locator package.** A package without Cordis dependencies isolates `skill-office` from the service, but duplicates release, dependency-policy, and documentation configuration for one closely coupled component. No consumer outside the provider needs to locate Kit, so the location logic stays inside the provider. + +## Consequences + +Ordinary Web and SDK sessions without a bundled environment list the four Office skills, which adds their summaries to the first context. Deployments without Kit see `unavailable` previews and `office_*` tools that report the missing Kit instead of failing at startup; skill loading does not check for Kit; a later Kit installation takes effect for conversion without reloading the provider. External compositions migrate from the `office-to-pdf` row, `ctx.officeToPdf`, and the `officeToPdf/*` Remote methods to the `office-kit` package through the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.md). + +Processes launched from the Desktop built-in terminal inherit `DSH_PRIMARY_RUNTIME`, so the SDK uses its bundled environment. The shell publishes no Kit location; child applications resolve their own Kit. + +Verification covers Kit lookup from the provider's Kit entry ([kit-lookup.spec.ts](../../../../packages/document/office-kit/tests/kit-lookup.spec.ts)), engine resolution and ASAR-unpacked path mapping ([engine-package.spec.ts](../../../../packages/document/office-kit/tests/engine-package.spec.ts)), independent engine resolution per Kit installation ([anchors.spec.ts](../../../../packages/document/office-kit/tests/anchors.spec.ts)), conversion retry after a missing or unloadable Kit, deduplicated warnings, the `office_*` tools' `unavailable` results with an absent Kit, recorded Web and SDK sessions that list the Office skills, and Office profile end-to-end tests. diff --git a/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.zh.md new file mode 100644 index 0000000000..4f8dd8192d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-cross-deployment-office-kit.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Office 技能在所有部署中可用,由单一 Office Kit 包提供 Kit + +Status: implemented + +[English](2026-10-26-cross-deployment-office-kit.md) | 中文 + +## 问题 + +原先只有 Desktop 和 Python SDK 挂载 Office 技能,普通 `dsh web` 用户无法借助同样的指导创建或检查 Office 文档。各部署定位 LibreOffice Kit 的方式也不同:转换提供方、技能和 Desktop 壳各自查找 Kit,Kit 缺失时转换配置行加载失败。没有下载 Office sidecar 的 Python SDK 可执行程序、没有原生引擎的源码检出,都属于部署缺少 Kit,而不是组合配置错误;让配置行失败会隐藏提供方和技能,而它们的大部分行为并不需要 Kit。 + +## 决策 + +[Web bundle](../../../../packages/bundle/web-app/cordis.patch.yml) 和 [SDK bundle](../../../../packages/bundle/sdk-app/cordis.patch.yml) 在 Desktop、普通 `dsh web` 和 SDK 中都挂载 [`skill-office`](../../../../packages/skill/skill-office/README.zh.md)。这取代了[渐进式 Office 技能](2026-09-27-progressive-office-skills.zh.md)中的挂载决策;其技能结构仍然有效,[按需激活的 Office 工具](2026-10-31-native-office-tool.zh.md)取代了其 shell 执行方式。 + +所有部署都从 `skill-office` 包的 `assets/` 读取 Office 技能,该目录只包含 `SKILL.md` 文件。Host 直接读取这些文件:Desktop 的 Electron Host 在 `app.asar` 内读取,SDK 可执行程序则内嵌这些文件,因此没有载体把技能复制到单独的资源目录。组合可以通过 `assetRoot` 选择其他目录,或用同名文件系统技能覆盖单个技能。 + +内置 Python 环境([`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md))只在 Desktop 以及有主运行时的 SDK 中挂载。`skill-office` 显式声明 `pythonEnvironment: 'workspace-dependencies' | 'user'`,由各 bundle 的配置行按部署选择取值。默认值为 `'workspace-dependencies'`,因为旧 SDK patch 会整体替换 `skill-office` 行的 `config`;这样的 patch 不含新键,仍保留它所针对的内置环境说明。 + +[`@deepseek-ai/dsh-office-kit`](../../../../packages/document/office-kit/README.zh.md) 负责 Kit 定位和共享转换服务。Kit 定位是提供方的内部实现,不对外导出:提供方传入自己的 `import.meta.resolve` 查找函数,原生引擎路径使用 ASAR 解包映射。定位不读取环境变量。[按需激活的 Office 工具](2026-10-31-native-office-tool.zh.md)在 Host 内执行 Kit 操作,因此任何部署都不发布 Kit CLI 路径或 Office 可执行文件变量。 + +Kit 缺失时降级,而不是加载失败。转换服务照常注册,以代码 `unavailable` 拒绝转换,只保留成功的 Kit 导入,并对每种不同的失败只报告一次;`office_render`、`office_convert` 和 `office_recalculate` 工具保持注册并在调用时返回该消息。 + +`office-to-pdf` 改为 `office-kit`,使后续的 Kit 消费方(例如原生渲染(PR #5374)和 `tool-office` 包)从同一个包获取 Kit。LibreOffice Kit 依赖在每个清单中声明同一个精确版本,因此转换器和打包的引擎来自同一个 Kit 发行版。 + +## 考虑过的替代方案 + +**普通 Web 继续不挂载 Office 技能。** 这样普通 Web 不会出现它可能无法执行的 Office 指导,但 Web 用户也无法使用只需要 Python 库的文档创作。现在没有内置环境时,技能会引导模型使用用户的 Python。 + +**`pythonEnvironment` 默认取 `'user'`。** 这与普通 Web 一致,但整体替换该行 `config` 的旧 SDK patch 会让类似 Desktop 的 SDK 部署悄然改用用户的 Python。 + +**在定位函数中读取部署环境变量。** 这样可以去掉 `cordis.yml` 表达式,但会把部署配置藏进库中,使源码、Web、Desktop 和 SDK 的行为依赖环境变量。bundle 本来就负责部署专有的表达式。 + +**Kit 缺失时让转换配置行失败。** 这样能在加载时报告缺失,符合配置错误应尽早报告的规则;但缺失属于部署环境而非配置,而且会停用不需要 Kit 的预览渲染路径和依赖技能。 + +**保留独立的定位包。** 没有 Cordis 依赖的独立包可以让 `skill-office` 与服务隔离,但要为一个紧密耦合的组件重复维护发布、依赖策略和文档配置。提供方以外没有消费方需要定位 Kit,因此定位逻辑留在提供方内部。 + +## 影响 + +没有内置环境的普通 Web 和 SDK 会话会列出四个 Office 技能,首个上下文因此增加它们的摘要。没有 Kit 的部署看到的是 `unavailable` 预览和报告 Kit 缺失的 `office_*` 工具,而不是启动失败;加载技能时不检查 Kit;之后安装的 Kit 无需重新加载提供方即可用于转换。外部组合按[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.zh.md)从 `office-to-pdf` 配置行、`ctx.officeToPdf` 和 `officeToPdf/*` Remote 方法迁移到 `office-kit` 包。 + +从 Desktop 内置终端启动的进程会继承 `DSH_PRIMARY_RUNTIME`,因此 SDK 使用其内置环境。Desktop 壳不发布 Kit 位置;子应用自行解析自己的 Kit。 + +验证覆盖从提供方 Kit 入口查找 Kit([kit-lookup.spec.ts](../../../../packages/document/office-kit/tests/kit-lookup.spec.ts))、引擎解析与 ASAR 解包路径映射([engine-package.spec.ts](../../../../packages/document/office-kit/tests/engine-package.spec.ts))、按 Kit 安装独立解析引擎([anchors.spec.ts](../../../../packages/document/office-kit/tests/anchors.spec.ts))、Kit 缺失或无法加载后的转换重试、去重后的警告、Kit 缺失时 `office_*` 工具的 `unavailable` 结果、列出 Office 技能的录制 Web 与 SDK 会话,以及 Office profile 端到端测试。 diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml new file mode 100644 index 0000000000..d09004d689 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-26-session-writable-directory-effects.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-26-session-writable-directory-effects.md +/agent-note-session-writable-directory-effects: + en: b5a98da65d44e212 + zh: ba6842a9b4a890f7 +/agent-note-session-writable-directory-effects/problem: + en: 2a32db1f27371403 + zh: 89cf04705ef66769 +/agent-note-session-writable-directory-effects/decision: + en: af67969841bedb67 + zh: edabdfaa30bcadd1 +/agent-note-session-writable-directory-effects/alternatives-considered: + en: dad77744dea17bde + zh: bb3838f9f9099944 +/agent-note-session-writable-directory-effects/consequences: + en: 1c2468df41bfbb46 + zh: 2118230c0091b20e diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md new file mode 100644 index 0000000000..2e6129df31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md @@ -0,0 +1,29 @@ +# Agent Note: Session writable-directory effects + +Status: implemented + +English | [中文](2026-10-26-session-writable-directory-effects.zh.md) + +## Problem + +Memory plugins need agent writes outside the workspace without granting every Session the same directory. Deployment-wide roots cannot express a plugin enabled for only one Session or a grant that ends when that plugin unloads. + +## Decision + +The sandbox-policy service owns effect-scoped directory registrations keyed by live Session identity. Configured grants remain shared; runtime grants apply only to their named Session. Each resolution captures the active union, and existing request-context logging records the directories advertised to the model. Resume and fork require new registrations rather than restoring authority from historical text. + +Registration disposal affects subsequent operations, not already-resolved calls or running processes. This deliberately permits persistent terminals and PTC environments to retain their launch grants until they stop. The requirement is to update ongoing Sessions' future executions without coordinating process cancellation or losing retained state. This narrows the different-policy reuse restriction in the [retained PTC sandbox decision](2026-10-08-persistent-ptc-sandbox.md) for additional directories only; mode-change invalidation and one-time escalation cleanup remain unchanged. + +Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules. + +## Alternatives considered + +**Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service. + +**Immediate process revocation.** Existing sandboxes apply grants at launch. Stopping and recreating processes would discard terminal or PTC state and require cleanup coordination beyond the accepted future-operation requirement. + +**Persist runtime grants as Session permissions.** Replaying such grants could authorize directories after the plugin that supplied them has been removed. The active plugin composition owns these grants. + +## Consequences + +Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocesses ignore extra roots while file tools enforce them; the [Windows scope decision](2026-10-29-windows-file-tool-directory-grants.md) records this limitation. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions. diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md new file mode 100644 index 0000000000..2e4c0a140d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 会话可写目录 effect + +Status: implemented + +[English](2026-10-26-session-writable-directory-effects.md) | 中文 + +## 问题 + +记忆插件需要允许 agent 在工作区之外写入,同时不向所有 Session 授予相同目录。部署级根目录无法表达只为一个 Session 启用的插件,或随插件卸载而结束的授权。 + +## 决策 + +sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周期管理的目录注册。配置授权仍然共享;运行时授权只适用于指定 Session。每次解析捕获当前授权的并集,现有请求上下文日志记录向模型展示的目录。恢复与派生需要重新注册,而非从历史文本恢复权限。 + +注册清理影响后续操作,不影响已经解析策略的调用或运行中的进程。这明确允许持久终端与 PTC 环境在停止前保留启动时的授权。需求是在不协调进程取消、不丢失保留状态的情况下更新运行中 Session 的后续执行。这仅针对额外目录收窄了[保留 PTC 沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中不同策略禁止复用的限制;模式变化失效与单次提权清理保持不变。 + +不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。 + +## 考虑过的替代方案 + +**共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。 + +**立即撤销进程权限。** 现有沙箱在启动时应用授权。停止并重新创建进程会丢失终端或 PTC 状态,并需要超出已接受的后续操作需求的清理协调。 + +**将运行时授权持久化为 Session 权限。** 回放此类授权可能在提供授权的插件已被移除后继续授权目录。当前插件组合拥有这些授权。 + +## 影响 + +同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程忽略额外根目录,文件工具则执行这些授权;[Windows 范围决策](2026-10-29-windows-file-tool-directory-grants.zh.md)记录此限制。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml index f54423c675..9ad135dbed 100644 --- a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml @@ -18,8 +18,8 @@ en: 9d68ffca5c8be681 zh: 36bb661f98de0ebb /agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/credential-resolution: - en: 32f59c35d71a4832 - zh: e9288313bb18b78d + en: 15f6fb26c8605260 + zh: 93467641a8ff0aad /agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/defaults-and-stored-selections: en: 5fcd3319fbceed1d zh: 20771646b9c36fa6 @@ -27,11 +27,11 @@ en: 2c7c84f8fa1e8745 zh: 5d0244e4f17ba12a /agent-note-web-provider-selection-independent-menus-over-registered-providers/alternatives-considered: - en: 0c227e270894cd71 - zh: 114484a3e69f5c0a + en: 2ec7f019ec3ce309 + zh: a6a5a8db1f1dd682 /agent-note-web-provider-selection-independent-menus-over-registered-providers/consequences: - en: 50f046bf81a7783d - zh: 5c0e27bdcaf9f12e + en: ea4d3f05e8be197a + zh: 81faeb3edf122869 /agent-note-web-provider-selection-independent-menus-over-registered-providers/verification: - en: 26ade0695094e9bc - zh: 7b265c43b1886da5 + en: 0db673b16e4aeda3 + zh: 3b086edecebdc5b5 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md index 1d37c118ec..82bfa34cfb 100644 --- a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md @@ -28,11 +28,11 @@ The Search and Fetch APIs read one `web-deepseek-api` namespace, and each resolv ### Credential resolution -Search and Fetch follow the DeepSeek credential of the conversation, and the settings page shows no credential control for them. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan. +Each of Search and Fetch has a billing setting, `web-deepseek-api.searchBilling` or `fetchBilling`, which the settings page edits as a **Billing** menu under the selected API. The default, `auto`, follows the DeepSeek credential of the conversation; `account` sends only the signed-in account's token and `api-key` only the API key, without falling back to the other. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan. -On the `deepseek-account` route, with an active, valid `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, the operation sends only the account token that `ctx.deepseekAccount` releases for the dispatch endpoint under that route's origin with `/v1/tools`; an HTTP 401 invalidates that token. Every other operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing key makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance naming the Models page, the reference, and the account sign-in. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` without reading a credential or sending the request, so a broken gateway setting never redirects requests to the official endpoint. +The account token is the one `ctx.deepseekAccount` releases for the dispatch endpoint under the origin of the active `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, with `/v1/tools`; without that entry, the origin of that route's default base URL is used. An HTTP 401 invalidates that token. With `auto` billing on the `deepseek-account` route, the operation sends the account token when that entry exists and a token is released. Every other `auto` operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. With `auto` billing on a route other than the two DeepSeek routes, or outside a Session, an operation without a configured key sends the account token instead, so a signed-in user without a key keeps web tools in conversations on other model providers; on the `deepseek-official` route it never sends the token, because that conversation bills the API key. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing credential makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance for its billing that names the account sign-in, the Models page and the reference, and the billing field. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` before reading the credential that depends on that route, without trying later sources or sending the request, so a broken gateway setting never redirects requests to the official endpoint. -A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect. +A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead, whatever its billing, always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect. The [credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) of `@deepseek-ai/dsh-llm-deepseek` read and validate API keys, check key presence, and release account tokens for the DeepSeek model routes, Messages search, and the Search and Fetch APIs, so every DeepSeek request reads the credential store the same way; the account model route, Messages search, and the Search and Fetch APIs also invalidate the token that an HTTP 401 rejects. Shipped profiles need no per-profile credential override, because the API-key path works wherever `DEEPSEEK_API_KEY` or the DeepSeek API-key route has a key. @@ -66,7 +66,7 @@ The Messages provider owns and writes `web/deepseek-search-llm-request`. The dec **Default fetch to the Fetch API as well.** It adds a per-request charge to every fetch and delegates page retrieval and target access policy to the service, which fetches from its own side rather than from the user's machine. -**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. Following the conversation's credential needs no setting, and the YAML-only own fields cover endpoints that differ from the model provider. +**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. The default `auto` billing follows the conversation's credential without configuration, the billing menu offers only the two DeepSeek credentials, and the YAML-only own fields cover endpoints that differ from the model provider. **Share one custom key and base URL between Search and Fetch.** It cannot express a deployment that serves the two APIs from different gateways. @@ -76,10 +76,10 @@ The Messages provider owns and writes `web/deepseek-search-llm-request`. The dec ## Consequences -Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search only in conversations on the account route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup. +Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search with `auto` billing in every conversation except those on the DeepSeek API-key route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup. A plugin provider, including one written in creator mode, appears in the matching menu as soon as it registers, under its `title`. The menus can offer a provider that cannot run for the current request; the failure appears when a search or fetch runs. Each registry change costs one forwarded event and one list read in each Client that observes it. ## Verification -[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the absence of credential controls for the DeepSeek APIs, and the settings each provider keeps. +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, the account fallback without a key outside the DeepSeek routes, account-only and API-key-only billing on every route, own fields over billing, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the billing menus of the DeepSeek APIs, and the settings each provider keeps. diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md index 7b9b5a75da..f995d39d19 100644 --- a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md @@ -28,11 +28,11 @@ Search 与 Fetch API 读取同一个 `web-deepseek-api` namespace,并各自解 ### 凭据解析 -Search 与 Fetch 跟随会话的 DeepSeek 凭据,设置页不为它们显示凭据控件。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。 +Search 与 Fetch 各有一个计费方式设置 `web-deepseek-api.searchBilling` 或 `fetchBilling`,设置页在选中的 API 下方以**计费方式**下拉框编辑它。默认值 `auto` 跟随会话的 DeepSeek 凭据;`account` 只发送已登录账号的令牌,`api-key` 只发送 API 密钥,都不回退到另一种凭据。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。 -在 `deepseek-account` 路由上,若模型提供方目录列出了入口根部活动且有效的 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目,操作只发送 `ctx.deepseekAccount` 为该路由 origin 加 `/v1/tools` 下的请求端点给出的账号令牌;HTTP 401 会作废该令牌。其他所有操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少密钥时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息点名模型页面、该引用和账号登录。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并以 `WEB_PROVIDER_MISCONFIGURED` 失败,不读取凭据、不发送请求,因此网关设置出错时请求不会被改发到官方端点。 +账号令牌是 `ctx.deepseekAccount` 为模型提供方目录列出的入口根部活动 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目的 origin 加 `/v1/tools` 下的请求端点给出的令牌;没有该条目时使用该路由默认基础地址的 origin。HTTP 401 会作废该令牌。`auto` 计费在 `deepseek-account` 路由上,若存在该条目且取得令牌,操作发送账号令牌。其他所有 `auto` 操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。`auto` 计费在两个 DeepSeek 路由之外的路由上或会话之外,没有配置密钥的操作改为发送账号令牌,因此已登录但没有密钥的用户在其他模型提供方的会话中仍可使用网页工具;在 `deepseek-official` 路由上从不发送令牌,因为该会话按 API 密钥计费。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少凭据时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息按计费方式点名账号登录、模型页面与该引用,以及计费方式字段。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并在读取依赖该路由的凭据前以 `WEB_PROVIDER_MISCONFIGURED` 失败,不再尝试后续来源,也不发送请求,因此网关设置出错时请求不会被改发到官方端点。 -在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。 +在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则无论计费方式如何,始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。 `@deepseek-ai/dsh-llm-deepseek` 的[凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)为 DeepSeek 模型路由、Messages 搜索以及 Search 与 Fetch API 读取并校验 API 密钥、检查密钥是否存在、取得账号令牌,因此每个 DeepSeek 请求都以同一方式读取凭据库;账号模型路由、Messages 搜索以及 Search 与 Fetch API 还会作废被 HTTP 401 拒绝的令牌。内置配置不需要按 profile 覆盖凭据,因为只要 `DEEPSEEK_API_KEY` 或 DeepSeek API 密钥路由有密钥,API 密钥路径就能工作。 @@ -66,7 +66,7 @@ Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声 **抓取也默认使用 Fetch API。** 它会给每次抓取增加按次费用,并把页面抓取和目标访问策略交给服务端,由服务端而不是用户的机器发起抓取。 -**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。跟随会话的凭据不需要任何设置,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。 +**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。默认的 `auto` 计费无需配置即跟随会话的凭据,计费方式下拉框只提供两种 DeepSeek 凭据,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。 **Search 与 Fetch 共用一个自定义密钥和基础地址。** 它无法表达由不同网关分别提供两个 API 的部署。 @@ -76,10 +76,10 @@ Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声 ## 影响 -没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户只在账号路由上的会话中可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。 +没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户在 `auto` 计费下,除 DeepSeek API 密钥路由上的会话外,都可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。 插件提供方(包括在创作模式下编写的提供方)一经注册,就会以其 `title` 出现在对应的下拉框中。下拉框可能提供在当前请求中无法运行的提供方;失败会在执行搜索或抓取时出现。每次注册表变更会在每个观察它的 Client 中产生一次转发事件和一次列表读取。 ## 验证 -[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 不显示凭据控件,以及各提供方保留的设置。 +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、DeepSeek 路由之外没有密钥时回退到账号、在每个路由上只用账号或只用 API 密钥的计费、单独字段优先于计费方式、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 的计费方式下拉框,以及各提供方保留的设置。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.i18n.yaml new file mode 100644 index 0000000000..4b928edd3a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-windows-file-tool-directory-grants.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-29-windows-file-tool-directory-grants.md +/agent-note-windows-additional-directory-grants-stay-in-file-tools: + en: aea9858c74dfd36e + zh: 4064573838d6000f +/agent-note-windows-additional-directory-grants-stay-in-file-tools/problem: + en: da14bde7ed3eae49 + zh: 6aad2181b2756b50 +/agent-note-windows-additional-directory-grants-stay-in-file-tools/decision: + en: acec40cda6036f2c + zh: 149c9eecb9863643 +/agent-note-windows-additional-directory-grants-stay-in-file-tools/alternatives-considered: + en: 09d6cceef8004a65 + zh: e9009eb5bbae1d87 +/agent-note-windows-additional-directory-grants-stay-in-file-tools/consequences: + en: a37925f508bb5644 + zh: f5a6cb4e099f94c6 diff --git a/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md new file mode 100644 index 0000000000..05b43edbfb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md @@ -0,0 +1,25 @@ +# Agent Note: Windows additional directory grants stay in file tools + +Status: implemented + +English | [中文](2026-10-29-windows-file-tool-directory-grants.zh.md) + +## Problem + +Memory plugins need session-specific writes to directories such as `~/.dsh/memory`. Extending Windows process access through ACLs requires managing persistent filesystem grants and token identities for changing directory combinations, increasing reliance on a backend with existing enforcement limitations. + +## Decision + +On Windows, the [session directory effects](2026-10-26-session-writable-directory-effects.md) authorize extra directories only through the harness filesystem checks used by `write` and `edit`. The process provider ignores extra roots and retains its workspace and private-temp permissions. The ACL package, runner arguments, and workspace SID remain unchanged. POSIX process backends continue to honor extra roots. + +Windows process support for extra roots is deferred until a better sandbox backend can enforce it without extending the ACL customization. Model context explicitly distinguishes file-tool grants from Windows process permissions. + +## Alternatives considered + +**One SID per directory or directory set.** These designs could select process authority for each execution, but require more ACL mutation, identity management, and lifecycle handling. The capability does not justify expanding this mechanism. + +**Reject processes whenever extra grants exist.** This prevents unrelated PowerShell and workspace operations from running when a memory plugin registers a directory, even if the command never accesses it. + +## Consequences + +A Windows memory plugin can use `write` and `edit` for its registered directory while ordinary workspace commands continue to work. A shell command or PTC program cannot use that extra grant. This deliberately accepts different capabilities across file tools and process tools; registration and disposal remain session-scoped, and existing processes retain their launch-time permissions. diff --git a/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md new file mode 100644 index 0000000000..7d005c8597 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Windows 额外目录授权仅用于文件工具 + +Status: implemented + +[English](2026-10-29-windows-file-tool-directory-grants.md) | 中文 + +## 问题 + +记忆插件需要针对会话向 `~/.dsh/memory` 等目录写入。通过 ACL 扩展 Windows 进程访问权限,需要为不断变化的目录组合管理持久文件系统授权与令牌身份,从而加深对已有执行限制的后端的依赖。 + +## 决策 + +在 Windows 上,[会话目录 effect](2026-10-26-session-writable-directory-effects.zh.md) 仅通过 `write` 与 `edit` 使用的 Harness 文件系统检查授权额外目录。进程提供方忽略额外根目录,保留工作区与私有临时目录权限。ACL 包、runner 参数与工作区 SID 保持不变。POSIX 进程后端继续执行额外根目录授权。 + +Windows 进程的额外根目录支持推迟到更好的沙箱后端能够实现该功能、无需扩展 ACL 定制时。模型上下文明示文件工具授权与 Windows 进程权限的区别。 + +## 考虑过的替代方案 + +**为每个目录或目录集合分配 SID。** 这些设计可以逐次执行选择进程权限,但需要更多 ACL 修改、身份管理与生命周期处理。该能力不足以证明扩展此机制的必要性。 + +**存在额外授权时拒绝进程。** 当记忆插件注册目录时,这会阻止无关的 PowerShell 和工作区操作运行,即使命令从不访问该目录。 + +## 影响 + +Windows 记忆插件可以通过 `write` 与 `edit` 访问注册的目录,同时普通工作区命令继续运行。shell 命令或 PTC 程序无法使用该额外授权。这明确接受文件工具与进程工具的能力差异;注册和清理仍限定于会话,已有进程保留启动时的权限。 diff --git a/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.i18n.yaml new file mode 100644 index 0000000000..ee576a9a66 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-31-native-office-tool.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-31-native-office-tool.md +/agent-note-on-demand-office-tools-for-rendering-conversion-and-recalculation: + en: ac1af3d918f27b6f + zh: 5822ce52c384fca3 +/agent-note-on-demand-office-tools-for-rendering-conversion-and-recalculation/problem: + en: 5b23962ecc835664 + zh: c96ea8720ebc9419 +/agent-note-on-demand-office-tools-for-rendering-conversion-and-recalculation/decision: + en: 25ffb4e0d42e9435 + zh: f9cd42a1807a48ca +/agent-note-on-demand-office-tools-for-rendering-conversion-and-recalculation/alternatives-considered: + en: f36e8534958e5a46 + zh: 96a3876a26a9d9ac +/agent-note-on-demand-office-tools-for-rendering-conversion-and-recalculation/consequences: + en: 8ea5f5c493dfae3d + zh: 29d7160bd3f98512 diff --git a/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.md b/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.md new file mode 100644 index 0000000000..a0a9698b8d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.md @@ -0,0 +1,47 @@ +# Agent Note: On-demand Office tools for rendering, conversion, and recalculation + +Status: implemented + +English | [中文](2026-10-31-native-office-tool.zh.md) + +## Problem + +Office operation skills ran the LibreOffice Kit CLI through ordinary shell commands, as [progressive Office skills](2026-09-27-progressive-office-skills.md) decided. Kit's native engine starts helper processes, Workers, and font caches that the shell sandbox and PTC permissions restrict, so the same render could succeed in one deployment and fail in another. Each deployment had to publish a standalone Node executable and a CLI path into the skill body, which required Node and CLI configuration, a Python sidecar Node, an Electron check against the application binary, and a runtime-root containment check against the Electron shell's own Kit. Rendering for inspection wrote PNG files into a temporary workspace directory that the model then read with `read_image` and deleted, adding calls and cleanup obligations to every visual check. + +## Decision + +The [`tool-office` package](../../../../packages/document/tool-office/README.md) exposes `office_render`, `office_convert`, and `office_recalculate` as three on-demand Host tools. Each tool states its parameter rules in its own JSON Schema: `office_render` accepts document paths, page or slide `pages`, worksheet `sheet` and `range` selection, and `dpi`; `office_convert` accepts all nine output formats (`pdf`, `docx`, `odt`, and `txt` for Word; `pdf`, `xlsx`, `ods`, and `csv` for Excel; `pdf`, `pptx`, and `odp` for PowerPoint) plus `sheet` for CSV output (required when the workbook has several sheets); and `office_recalculate` accepts `xlsx` and `ods`. Together the three declarations total ~1,813 characters, paid only in Sessions that load an Office skill. They call the [Office kit](../../../../packages/document/office-kit/README.md) service, which loads Kit in the Host process through its `src/locate.ts` resolver and runs every operation through the queue, concurrency limits, byte reservations, and cache that Office previews use. Tool requests use `foreground` priority, the class of an open preview, so background prewarming cannot starve them. The SDK composition mounts `tool-office` beside `office-kit`, and the Web bundle's standard and PTC agent presets mount it while its minimal preset keeps its fixed roster; when Kit is missing the tools still register and every call returns the service's `unavailable` message. Each tool reads the service at each call instead of injecting it, so a composition that omits or disables `office-kit` still creates Sessions with the tools and each call fails as `unavailable`; a service loaded later serves the next call. Standard and PTC modes reach the same registrations; nested PTC calls re-enter the ordinary execution pipeline. + +`office_render` returns PNG images in the tool result; the Web Client's Office row shows only the result's text blocks and does not display the images. The tool commits the images through the attachment store before the result is logged, so the model-visible images are reconstructable from the Session log, resume and fork restore them, and image offload applies to them. The complete result is bounded by image count, per-image pixels and dimensions, and total PNG bytes, each the lower of the tool configuration and the attachment store's limits; an over-limit selection fails instead of being truncated. A route without image input is refused before rendering. + +`office_convert` and `office_recalculate` do not write into the workspace. The filesystem service intentionally provides only binary reads, and binary files in the workspace come only from the shell, whose sandbox and approvals already govern writes. The Office kit saves each output in an owner-only directory under its configurable `outputDirectory` (`$DSH_HOME/office-kit/outputs` by default), grouped by the root Session of the caller's subagent tree, and deletes it after `outputRetentionMs`, when `maxOutputStoreBytes` needs room for newer outputs, when that root Session is disposed, or when the provider unloads. A subagent's output therefore outlives the subagent, so the delegating agent can still copy it. Each tool returns the absolute temporary path, format, size, and the page count the converter reports, with the instruction to copy the file with the shell now, because it may be deleted after a retention period or to free space, and is deleted when the conversation ends. A filesystem backend that cannot read Host paths, such as an SSH workspace, refuses these two tools before converting, because a remote shell cannot read the Host output store. Shell sandboxes allow reads of the Harness home on macOS Seatbelt, Linux bubblewrap, and Linux Landlock, so a confined shell can copy the file into the workspace. + +The three tools share one `when(agent)` predicate instead of staying resident in every request. An `Agent` activates them when `marked.has(agent)` is true or when `agent.session.requestHeader()?.tools` already lists `office_render`, `office_convert`, or `office_recalculate`; `agent/disposed` clears the `WeakSet` mark. `tool-office` marks the `Agent` on a non-error `skill` tool result (`tools/result` with `!result.isError`, including a nested `skill` call inside `run_code`) or a direct user skill invocation (`session/event` where `event.type === 'user/message'` and `event.data.source.kind === 'skill-invocation'`) whose skill name is in `activationSkills` (`office`, `office-docx`, `office-xlsx`, and `office-pptx` by default). The declarations appear on the step after the skill loads and stay active for the rest of the Session; calling an `office_*` tool before activation fails as `UNKNOWN_TOOL`. When mid-session activation adds the tools, the agent loop records a `developer/message` (`source: { kind: 'tool-registry' }`) with `tool-addition` items: models that declare `toolUpdate` (`in-history` or `addition-only`) keep their existing request prefix, whereas models without `toolUpdate` start a new request series once. Because standard-mode request headers are already persisted in the Session log, checking `requestHeader()` preserves activation across resume, fork, and compaction without a Session projection. In PTC mode, the recorded request header declares only `run_code`, so a resumed or forked PTC Session (or a process crash between loading a skill and recording the next native request header) does not retain `office_*` bindings until an Office skill loads again; the [Office router and format skills](../../../../packages/skill/skill-office/README.md) instruct the model to load `office` when `office_render`, `office_convert`, or `office_recalculate` is not in the tool list. No skill wraps a single operation, and a standalone render, recalculation, or conversion loads only the `office` router. The skills carry no executable paths, so `skill-office` has no Node, CLI, or Kit location configuration, `office-kit`'s `src/locate.ts` resolves no CLI, Desktop and the bundles publish no Office executable variables, and the Python sidecar's standalone Node remains only for the Python `deepseek_harness.office` module. + +This decision partially supersedes [progressive Office skills](2026-09-27-progressive-office-skills.md): its router and format skills remain current, while its operation skills, ordinary-shell execution, and rejection of a dedicated renderer tool are replaced here. + +## Alternatives considered + +**Keep shell execution of the Kit CLI.** It reuses shell approvals and process management, but the native engine runs restricted under shell sandboxes and PTC permissions, and every deployment must maintain Node and CLI paths, an Electron Node check, and runtime-root containment. + +**Keep one resident `office` tool.** One resident schema (~1,514 characters) avoids mid-session tool additions, but charges every non-Office Session on every request and forces rendering, conversion, and recalculation parameters into a single flat schema where per-operation parameter combinations must be explained in prose instead of enforced per tool. + +**Add a Session projection for loaded Office skills.** Folding `tools/result` and `user/message` records in a dedicated projection would reconstruct skill activation across PTC resume and fork, but requires a new projection service, fold rules, and cross-package dependencies for state that standard-mode request headers already record and that PTC mode recovers by reloading `office`. + +**Track activation only in `Agent` memory.** Checking only `WeakSet` (as `action-cards` does) drops the `office_*` declarations on the first step after a standard-mode resume or fork because the new `Agent` instance starts unmarked; consulting `agent.session.requestHeader()?.tools` preserves the declarations from the persisted header. + +**Keep one skill per operation.** Separate render, recalculation, and PDF export skills add a `skill` call before each standalone operation and repeat what the tool schema and results already state. + +**Write converted files into the workspace from the Host.** This saves the shell copy, but needs binary creation in the filesystem service or a second write path beside it, both of which would duplicate the shell's sandbox and approval decisions for workspace writes. + +**Return converted files through the system temporary directory.** Linux bubblewrap mounts a private `/tmp` for confined shells, so the shell could not read the file there; the Harness home is readable under every local shell sandbox. + +**Return image files instead of image content.** Saving PNGs for a later `read_image` call keeps one image path for all tools, but repeats the temporary-directory workflow this change removes and adds a call per inspection. + +## Consequences + +Visual inspection takes one `office_render` call after loading an Office skill and needs no cleanup, and its images follow the Session's attachment, offload, and replay rules. Sessions that do not load an Office skill pay zero Office tool-declaration tokens; Sessions that load one activate ~1,813 characters of operation-specific schemas, keeping the existing request prefix on models with `toolUpdate` and starting one new request series on models without it. Standard-mode resume, fork, and compaction retain the tools through the recorded request header, while PTC resume or fork and crashes before the next recorded header recover by loading `office` again. Office operations behave the same in standard and PTC modes and across deployments once activated, and the deployment-specific Node, CLI, and containment code is gone. + +Converted files need a shell copy; compositions without a shell tool can render but cannot deliver converted files, and remote workspaces cannot convert at all. Temporary outputs disappear after retention or root Session disposal, so a resumed Session must convert again. Saving page images as files is not offered. + +Verification covers the three tools' schemas, results, argument rules, limits, failures, cancellation, PTC dispatch, and disposal in `tool-office.spec.ts`, and skill-driven activation, custom `activationSkills`, failed skill calls, direct `/office` invocation, `requestHeader()` persistence across resume and fork, and PTC reactivation in `activation.spec.ts`; the output store's retention, eviction, release, and sweeping in Office kit tests; Loader compositions that return the `unavailable` message with Kit absent and with the `office-kit` row absent, then reach the service once the row loads; packaged Desktop smoke checks that render, convert, and recalculate real DOCX, XLSX, and PPTX files through the service; and recorded headless sessions that omit `office_*` before an Office skill loads and declare `office_render`, `office_convert`, and `office_recalculate` on the step after loading one, including a render that `office_render` refuses for a route without image input. diff --git a/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.zh.md b/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.zh.md new file mode 100644 index 0000000000..91e10385ed --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-31-native-office-tool.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 按需激活的 Office 渲染、转换与重算工具 + +Status: implemented + +[English](2026-10-31-native-office-tool.md) | 中文 + +## 问题 + +按照[渐进式 Office 技能](2026-09-27-progressive-office-skills.zh.md)的决定,Office 操作技能通过普通 shell 命令运行 LibreOffice Kit CLI。Kit 的原生引擎会启动辅助进程、Worker 和字体缓存,而 shell 沙箱和 PTC 权限会限制这些行为,因此同一次渲染可能在一个部署中成功、在另一个部署中失败。每个部署都必须把独立 Node 可执行文件和 CLI 路径发布到技能正文中,这需要 Node 和 CLI 配置、Python sidecar 的 Node、针对应用二进制的 Electron 检查,以及防止选中 Electron 壳自带 Kit 的运行时根目录约束检查。用于检查的渲染把 PNG 文件写入工作区内的临时目录,模型再用 `read_image` 读取并删除它们,使每次视觉检查都多出调用和清理义务。 + +## 决策 + +[`tool-office` 包](../../../../packages/document/tool-office/README.zh.md)提供三个按需激活的 Host 工具:`office_render`、`office_convert` 和 `office_recalculate`。每个工具在各自的 JSON Schema 中直接声明参数规则:`office_render` 接受文档路径、页码或幻灯片 `pages`、工作表 `sheet` 与 `range` 选择以及 `dpi`;`office_convert` 保留全部九种输出格式(Word 支持 `pdf`、`docx`、`odt`、`txt`,Excel 支持 `pdf`、`xlsx`、`ods`、`csv`,PowerPoint 支持 `pdf`、`pptx`、`odp`)以及仅用于 CSV 输出的 `sheet`(工作簿含多个工作表时必填);`office_recalculate` 接受 `xlsx` 和 `ods`。三个工具声明共约 1,813 个字符,仅在加载了 Office 技能的 Session 中付出。它们调用 [Office 套件](../../../../packages/document/office-kit/README.zh.md)服务;该服务通过 `src/locate.ts` 在 Host 进程中加载 Kit,并让每个操作经过 Office 预览所用的队列、并发上限、字节预留和缓存。工具请求使用 `foreground` 优先级,即打开的预览所属的类别,因此后台预热不会饿死它们。SDK 组合在 `office-kit` 旁挂载 `tool-office`,Web bundle 的标准和 PTC Agent 预设挂载它,minimal 预设保持其固定工具列表;Kit 缺失时三个工具仍然注册,每次调用都返回服务的 `unavailable` 消息。每个工具在每次调用时读取该服务而不是注入它,因此省略或禁用 `office-kit` 的组合仍能带着这些工具创建 Session,每次调用以 `unavailable` 失败;之后加载的服务会处理下一次调用。标准模式与 PTC 模式使用同一组注册;PTC 嵌套调用重新进入普通执行管线。 + +`office_render` 在工具结果中返回 PNG 图片;Web Client 的 Office 行只显示结果的文本块,不显示这些图片。工具在结果写入日志之前通过附件存储提交图片,因此模型可见的图片可以从 Session 日志还原,恢复和分叉会还原它们,图片卸载也适用于它们。完整结果受图片数量、单图像素与尺寸以及 PNG 总字节数约束,每项取工具配置与附件存储上限中的较小值;超出上限的选择直接失败,不会被截断。不支持图片输入的路由在渲染前即被拒绝。 + +`office_convert` 和 `office_recalculate` 不写入工作区。文件系统服务有意只提供二进制读取,工作区中的二进制文件只由 shell 产生,而 shell 的沙箱和审批已经管理写入。Office 套件把每个输出保存在可配置的 `outputDirectory`(默认 `$DSH_HOME/office-kit/outputs`)下仅属主可访问的目录中,按调用方 subagent 树的根 Session 分组;输出在 `outputRetentionMs` 到期、`maxOutputStoreBytes` 需要为较新的输出腾出空间、该根 Session 卸载或提供方卸载时删除。因此 subagent 的输出在 subagent 结束后仍然保留,委派它的 agent 仍可复制。工具返回临时文件的绝对路径、格式、大小和转换器报告的页数,并指示立即用 shell 复制该文件,因为文件可能在保留期到期或需要腾出空间时被删除,且对话结束后会被删除。无法读取 Host 路径的文件系统后端(例如 SSH 工作区)会在转换前拒绝这两个工具,因为远程 shell 读不到 Host 的输出存储。macOS Seatbelt、Linux bubblewrap 和 Linux Landlock 的 shell 沙箱都允许读取 Harness 主目录,因此受限的 shell 可以把文件复制进工作区。 + +三个工具共享同一个 `when(agent)` 谓词,而不是在每次请求中常驻。当 `marked.has(agent)` 为真,或 `agent.session.requestHeader()?.tools` 已包含 `office_render`、`office_convert` 或 `office_recalculate` 时,`Agent` 激活它们;`agent/disposed` 会清除 `WeakSet` 标记。当非错误的 `skill` 工具结果(`tools/result` 且 `!result.isError`,包括 `run_code` 内嵌套的 `skill` 调用)或直接的用户技能调用(`session/event` 中 `event.type === 'user/message'` 且 `event.data.source.kind === 'skill-invocation'`)加载了 `activationSkills`(默认为 `office`、`office-docx`、`office-xlsx` 和 `office-pptx`)中的技能时,`tool-office` 标记该 `Agent`。工具声明在加载技能后的下一步出现,并在该 Session 剩余时间内保持激活;激活前调用 `office_*` 工具会以 `UNKNOWN_TOOL` 失败。会话中途激活新增这些工具时,agent loop 会记录一条带 `tool-addition` 项的 `developer/message`(`source: { kind: 'tool-registry' }`):声明了 `toolUpdate`(`in-history` 或 `addition-only`)的模型保留现有请求前缀,未声明 `toolUpdate` 的模型则开启一次新的请求序列。由于标准模式的请求头本就持久化在 Session 日志中,检查 `requestHeader()` 即可在恢复、分叉和压缩后保留激活状态,无需新增 Session projection。PTC 模式下记录的请求头只声明 `run_code`,因此恢复或分叉的 PTC Session(或加载技能后、下一条原生请求头记录前发生进程崩溃)不会保留 `office_*` 绑定,直到再次加载 Office 技能;[Office 路由与格式技能](../../../../packages/skill/skill-office/README.zh.md)指示模型在 `office_render`、`office_convert` 或 `office_recalculate` 不在工具列表中时调用 `skill` 加载 `office`。没有技能只包装单个操作,单独的渲染、重算或转换只需加载 `office` 路由。技能不再携带可执行文件路径,因此 `skill-office` 没有 Node、CLI 或 Kit 位置配置,`office-kit` 的 `src/locate.ts` 没有 CLI 解析器,Desktop 和各 bundle 不发布 Office 可执行文件变量,Python sidecar 的独立 Node 只保留给 Python `deepseek_harness.office` 模块使用。 + +此决定部分取代[渐进式 Office 技能](2026-09-27-progressive-office-skills.zh.md):其路由与格式技能仍然有效,而其操作技能、普通 shell 执行方式以及否决专用渲染工具的决定由本文取代。 + +## 考虑过的替代方案 + +**保留通过 shell 执行 Kit CLI。** 这样可复用 shell 的审批和进程管理,但原生引擎在 shell 沙箱和 PTC 权限下运行受限,而且每个部署都要维护 Node 与 CLI 路径、Electron 的 Node 检查以及运行时根目录约束。 + +**保留单一常驻 `office` 工具。** 单一常驻 schema(约 1,514 个字符)虽能避免会话中途新增工具,但会让所有不使用 Office 的 Session 在每次请求中都承担这笔开销,并迫使渲染、转换和重算参数挤在同一个扁平 schema 中,各操作的参数组合只能靠自然语言说明而无法按工具分 schema 约束。 + +**为已加载的 Office 技能新增 Session projection。** 在专用 projection 中折叠 `tools/result` 和 `user/message` 记录虽可在 PTC 恢复和分叉后还原技能激活状态,但需要新增 projection 服务、折叠规则和跨包依赖,而标准模式已由持久化的请求头记录该状态,PTC 模式只需重新加载 `office` 即可恢复。 + +**仅在 `Agent` 内存中记录激活状态。** 若像 `action-cards` 那样只检查 `WeakSet`,标准模式恢复或分叉后的第一步会丢失 `office_*` 声明,因为新的 `Agent` 实例初始未标记;同时检查 `agent.session.requestHeader()?.tools` 可从已持久化的请求头保留声明。 + +**为每个操作保留一个技能。** 单独的渲染、重算和 PDF 导出技能会在每次单独操作前多一次 `skill` 调用,并重复工具 schema 与结果中已有的说明。 + +**由 Host 把转换后的文件写入工作区。** 这样省去 shell 复制,但需要文件系统服务支持创建二进制文件,或在其旁另建一条写入路径,两者都会重复 shell 对工作区写入的沙箱和审批决策。 + +**经系统临时目录返回转换后的文件。** Linux bubblewrap 为受限 shell 挂载私有的 `/tmp`,shell 在那里读不到文件;Harness 主目录在所有本地 shell 沙箱下都可读。 + +**返回图片文件而不是图片内容。** 先保存 PNG 再调用 `read_image`,能让所有工具共用一种图片路径,但会重现本次变更去除的临时目录流程,每次检查还多一次调用。 + +## 影响 + +加载 Office 技能后,视觉检查只需一次 `office_render` 调用且无需清理,图片遵循 Session 的附件、卸载和回放规则。未加载 Office 技能的 Session 不消耗任何 Office 工具声明 token;加载了 Office 技能的 Session 激活约 1,813 个字符的专用 schema,支持 `toolUpdate` 的模型保留现有请求前缀,不支持 `toolUpdate` 的模型只开启一次新的请求序列。标准模式的恢复、分叉和压缩通过已记录的请求头保留工具声明,PTC 恢复或分叉以及在记录下一条请求头前崩溃的场景则通过再次加载 `office` 恢复。激活后,Office 操作在标准模式与 PTC 模式之间、在不同部署之间行为一致,各部署专用的 Node、CLI 和约束代码已经删除。 + +转换后的文件需要 shell 复制;没有 shell 工具的组合可以渲染,但无法交付转换后的文件,远程工作区则完全不能转换。临时输出在保留期满或根 Session 卸载后消失,恢复的 Session 需要重新转换。不提供把页面图片保存为文件。 + +验证包括:`tool-office.spec.ts` 覆盖三个工具的 schema、结果、参数规则、上限、失败、取消、PTC 分发和卸载,`activation.spec.ts` 覆盖技能触发激活、自定义 `activationSkills`、失败的技能调用、直接 `/office` 调用、跨恢复与分叉的 `requestHeader()` 持久化以及 PTC 重新激活;Office 套件测试覆盖输出存储的保留、淘汰、释放和清扫;Loader 组合测试在 Kit 缺失时以及 `office-kit` 条目缺失时返回 `unavailable` 消息,并在该条目加载后调用到服务;Desktop 打包 smoke 通过服务对真实 DOCX、XLSX 和 PPTX 文件执行渲染、转换和重算;录制的 headless Session 在加载 Office 技能前不声明 `office_*`,在加载后的下一步声明 `office_render`、`office_convert` 和 `office_recalculate`,其中包括一次因路由不接受图片输入而被 `office_render` 拒绝的渲染。 diff --git a/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.i18n.yaml new file mode 100644 index 0000000000..b5550a48c8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-11-02-subagent-catalog-names.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-11-02-subagent-catalog-names.md +/agent-note-subagent-names-live-in-the-creator-s-catalog: + en: 987d2272bb47fe75 + zh: 54e701ecbaa60457 +/agent-note-subagent-names-live-in-the-creator-s-catalog/problem: + en: d4798e7bbbd5d49d + zh: b388470cf1ff33ac +/agent-note-subagent-names-live-in-the-creator-s-catalog/decision: + en: 8fafb256536d1bc5 + zh: f45f9e6d6f9a2407 +/agent-note-subagent-names-live-in-the-creator-s-catalog/alternatives-considered: + en: 14b564fd848248da + zh: 5f65c05bbe22d581 +/agent-note-subagent-names-live-in-the-creator-s-catalog/consequences: + en: 4baa2557e5415ee1 + zh: 8e8d7819c04f9043 +/agent-note-subagent-names-live-in-the-creator-s-catalog/testing: + en: a7963eadbfe52c21 + zh: 10bfc9fb28f6ae73 diff --git a/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.md b/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.md new file mode 100644 index 0000000000..f4ab4dada3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.md @@ -0,0 +1,45 @@ +# Agent Note: Subagent names live in the creator's catalog + +Status: implemented + +English | [中文](2026-11-02-subagent-catalog-names.zh.md) + +## Problem + +Models coordinate subagents through `send_message`, `interrupt_agent`, `list_agents`, and Team task owners. Those tools addressed every member by a 36-character Session id. Models copy such ids poorly: they cannot recall which id belongs to which task, they mistype ids, and every listing, receipt, notice, and identity statement spends tokens on them. Descriptions explain responsibilities but repeat and cannot address a member. Models need short, readable references that survive restarts and resolve to exactly one member, without a second execution identity. + +## Decision + +The Session id remains the only execution identity for routing, authorization, persistence, task ownership, and navigation. A name is a model-facing reference to it. + +Each new child has a name that is unique among its creator's children. The name is a field of the `subagent/catalog` entry that the creator already appends when it creates the child, using catalog payload version 3. Local and parent-delivered external creation write it; caller-delivered external runs enter no catalog and take no name. A name is 1–32 lower-case letters, digits, or hyphens starting with a letter, and is never `lead`. A 36-character UUID therefore never parses as a name. Omission selects the first free `agent-N`. An explicit name already held by a sibling fails with `DUPLICATE_AGENT_NAME`, and an invalid name fails with `INVALID_AGENT_NAME`. + +The creator reserves the name synchronously at admission, before startup awaits anything. The reservation lives in process memory beside the creator's committed catalog names and is released when the catalog entry commits or startup fails. Task assignment copies the immutable full name into optional `ownerName` beside `ownerId` in the same task event. Task reads use this saved value and fall back to `ownerId`, so display does not require catalog traversal. + +The root is always `lead`. A descendant's full name is the slash-separated path of names from the root without a `lead/` prefix, such as `reviewer/checker`. Sibling-unique segments make full names unique in the tree. A child whose catalog entry predates names, and every descendant below it, keeps its Session id as its name. + +`ctx.subagents.resolveName()` returns the Session id for `lead` or a full name by reading only the catalogs along the path; any other text is returned unchanged as a Session id. Tools resolve before they call messaging, interruption, or Team assignment, and those operations authorize the resulting Session id. Resolution grants no authority. Runtime-authored text names agents by full name, while durable attribution such as `senderSessionId` and Team `ownerId` keeps Session ids. The [subagent runtime](../../../../packages/subagent/subagent/README.md#agent-names) owns the API contract and the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.md) owns caller migration. + +This supersedes the rejection of "unique names as sibling-routing opt-in" and the statement that creation accepts no member name in [subagent teammates](2026-10-15-agent-teams-as-subagents.md), and the Session-id identity text in [required runtime-context identity](2026-10-13-team-identity-runtime-context.md). Their single-identity, Team, and runtime-context retention decisions remain active. + +## Alternatives considered + +**Optional naming plugin with a root-Session binding event.** A separate plugin would record a name-to-Session binding in the root Session after the child starts. That adds a second write that can fail independently after startup succeeds, so a started child can lack its binding, and binding after start can report errors for children that are already running. The presentation plugin would then own cancellation of a lifecycle it does not control, would rewrite runtime-authored messages that name agents, and would still change model-visible schemas when it is absent, because the core tools must accept and render either form. Recording the name in the creator's existing catalog fact needs no second write, no rollback protocol, and no optional service. + +**Flat names unique across the tree.** A tree-wide flat namespace needs one registry at the root that every creation must consult and update, including creations by descendants whose root may be cold. Sibling-unique segments joined into paths are unique without a root registry, and each creator checks only its own catalog. + +**Recyclable names.** Releasing a name when its child settles would let a later sibling take it. A model that still remembers the earlier child would then message or interrupt a different agent. A name stays taken for as long as the catalog entry exists. + +**Automatic suffixes on conflict.** Turning a duplicate `reviewer` into `reviewer-2` would let creation succeed, but the model would believe it holds a name that the runtime never assigned. A duplicate fails so that the model chooses a name it knows. + +## Consequences + +Catalog payload version 3 is a same-format addition: `SESSION_FORMAT_VERSION` is unchanged, current readers accept versions 0 through 3, and releases that predate names reject version 3 loudly. The `subagentCatalog` projection state version remains 6 because old checkpoint facts keep their meaning and accept named suffix events; invalidating them would add full catalog folds to cold descendant observations without changing the recovered state. Native admission rejects a repeated sibling name in own catalog facts, so a log that could make name lookup ambiguous fails before any member is resolved. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-11-02-subagent-catalog-names.md) records the type change. + +Tool schemas grow by one optional `name` parameter on each delegation tool, and `send_message` and `interrupt_agent` rename `agent_id` to `agent`. These schema changes invalidate cached request prefixes once after upgrading. Identity runtime context, receipts, settlement notices, agent messages, and listing rows become shorter for shallow trees, while full names lengthen with nesting depth. `list_agents` drops parent ids and depth because a full name encodes both. + +Names cannot be renamed or recycled. Historical children have no names, and no migration assigns them. Name reservations are process-local, as is subagent residency. Tools still accept Session ids, so recorded Session ids and members below unnamed creators remain addressable. + +## Testing + +Runtime tests cover requested and default names, duplicate and invalid names rejected before startup, reservation across concurrent startup and release after failed startup, nested paths and `namesOf`, resolution of names, unknown text, and Session ids without granting authority, cold restoration of a member's path from the catalogs along its creation chain, and Session-id addressing below an unnamed historical creator. Catalog tests restore version-3 names beside unnamed historical entries, and the V4 format reader admits native version-3 catalog facts. Control-tool and Team-tool tests cover name-addressed messaging, listing rows, owner names, and historical owners shown by Session id. Recorded sessions pin the tool schemas, identity runtime context, receipts, listing rows, and Team policy text. diff --git a/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.zh.md b/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.zh.md new file mode 100644 index 0000000000..ec05878efb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-02-subagent-catalog-names.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Subagent 名称记录在创建者目录中 + +Status: implemented + +[English](2026-11-02-subagent-catalog-names.md) | 中文 + +## Problem + +模型通过 `send_message`、`interrupt_agent`、`list_agents` 和 Team 任务所有者协调 subagent(子智能体)。这些工具曾以 36 字符的 Session id 寻址每个成员。模型难以准确复制这类 id:记不清哪个 id 对应哪个任务,会输错 id,而且每次列表、回执、通知和身份声明都要为其消耗 token。描述能说明职责,但可以重复,无法寻址成员。模型需要简短、可读、跨重启有效且只解析到唯一成员的引用,同时不引入第二种执行身份。 + +## Decision + +Session id 仍是路由、授权、持久化、任务归属和导航使用的唯一执行身份。名称是指向它的面向模型的引用。 + +每个新子级都有一个名称,在其创建者的子级之间唯一。名称是创建者在创建子级时本就会追加的 `subagent/catalog` 条目中的一个字段,使用第 3 版目录载荷。本地创建和父级投递的外部创建写入名称;调用方投递的外部执行不进入目录,也没有名称。名称由 1–32 个小写字母、数字或连字符组成,以字母开头,且绝不能是 `lead`。因此 36 字符的 UUID 永远不会被解析为名称。省略时选择第一个空闲的 `agent-N`。显式请求同级已持有的名称会以 `DUPLICATE_AGENT_NAME` 失败,无效名称以 `INVALID_AGENT_NAME` 失败。 + +创建者在准入时、启动等待任何操作之前同步预留名称。预留保存在进程内存中,与创建者已提交的目录名称并列,并在目录条目提交或启动失败时释放。任务分配在同一个任务事件中,将不可变的完整名称复制到 `ownerId` 旁的可选 `ownerName`。任务读取使用此保存值,缺失时回退到 `ownerId`,因此展示无需遍历目录。 + +根的名称始终为 `lead`。后代的完整名称是从根开始、以斜杠分隔的名称路径,不带 `lead/` 前缀,例如 `reviewer/checker`。同级唯一的各段使完整名称在整棵树中唯一。目录条目早于名称机制的子级及其下所有后代继续以 Session id 作为名称。 + +`ctx.subagents.resolveName()` 只读取路径上的目录,为 `lead` 或完整名称返回 Session id;其他文本原样作为 Session id 返回。工具先解析名称,再调用消息、中断或 Team 分配,由这些操作对解析出的 Session id 授权。解析本身不授予权限。运行时撰写的文本以完整名称指称 agent,而 `senderSessionId` 和 Team `ownerId` 等持久归属仍保留 Session id。[subagent 运行时](../../../../packages/subagent/subagent/README.zh.md#agent-names)定义 API 约定,[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.zh.md)负责调用方迁移。 + +本决策取代 [Agent Teams 队友是子代理](2026-10-15-agent-teams-as-subagents.zh.md)中对“以唯一名称选择性启用同级路由”的否决以及“创建不接收成员名称”的表述,并取代[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)中以 Session id 表述身份的文本。它们的单一身份、Team 和运行时上下文保留决策仍然有效。 + +## Alternatives considered + +**带根 Session 绑定事件的可选命名插件。** 独立插件会在子级启动后,在根 Session 中记录名称到 Session 的绑定。这会增加第二次写入,且可能在启动成功后单独失败,导致已启动的子级缺少绑定;启动后再绑定还可能为已在运行的子级报告错误。展示用插件因此要负责取消一个不受它控制的生命周期,要改写指称 agent 的运行时消息;而且即使插件不存在,面向模型的 schema 仍会改变,因为核心工具必须接受并渲染两种形式。把名称记录在创建者已有的目录事实中,不需要第二次写入、回滚协议或可选服务。 + +**全树唯一的扁平名称。** 全树扁平命名空间需要根上的一个注册表,每次创建都要查询并更新它,包括根可能处于冷状态时由后代发起的创建。同级唯一的各段连接成路径后无需根注册表即可唯一,每个创建者只检查自己的目录。 + +**可回收名称。** 子级结算后释放名称会让之后的同级取得该名称。仍记得先前子级的模型会因此向另一个 agent 发消息或发出中断。只要目录条目存在,名称就保持占用。 + +**冲突时自动加后缀。** 把重复的 `reviewer` 改为 `reviewer-2` 能让创建成功,但模型会以为自己持有一个运行时从未分配的名称。重复名称直接失败,模型才会选择一个自己知道的名称。 + +## Consequences + +第 3 版目录载荷是同格式增量:`SESSION_FORMAT_VERSION` 不变,当前读取器接受第 0 至第 3 版,早于名称机制的发布会明确拒绝第 3 版。`subagentCatalog` 投影状态版本保持为 6,因为旧检查点事实的含义不变,且能继续处理具名后缀事件;使其失效会在冷后代观察时增加完整目录折叠,却不改变恢复结果。原生准入会拒绝自身目录事实中重复的同级名称,因此会让名称查找产生歧义的日志在解析任何成员之前就失败。[持久化确认记录](../../../../docs/persistence-changes/2026-11-02-subagent-catalog-names.zh.md)记录了该类型变更。 + +每个委派工具的 schema 增加一个可选 `name` 参数,`send_message` 和 `interrupt_agent` 把 `agent_id` 改名为 `agent`。这些 schema 变更会在升级后使缓存的请求前缀失效一次。对浅层树而言,身份运行时上下文、回执、结算通知、agent 消息和列表行都变短,完整名称则随嵌套深度变长。`list_agents` 不再携带父级 id 和深度,因为完整名称已包含这两项信息。 + +名称不能修改或回收。历史子级没有名称,也没有迁移为其分配名称。名称预留与 subagent 驻留一样仅限进程内。工具仍接受 Session id,因此已记录的 Session id 和无名创建者下的成员仍可寻址。 + +## Testing + +运行时测试覆盖请求名称与默认名称、启动前拒绝重复和无效名称、并发启动期间的预留和启动失败后的释放、嵌套路径与 `namesOf`、在不授予权限的前提下解析名称、未知文本和 Session id、从创建链上的目录冷恢复成员路径,以及无名历史创建者之下以 Session id 寻址。目录测试在无名历史条目旁恢复第 3 版名称,V4 格式读取器接纳原生第 3 版目录事实。控制工具和 Team 工具测试覆盖按名称发消息、列表行、所有者名称,以及以 Session id 显示的历史所有者。录制会话固定工具 schema、身份运行时上下文、回执、列表行和 Team 策略文本。 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 6646c5a4a1..53f09c1134 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -12,8 +12,8 @@ en: f962826fcf5e3949 zh: 8a9932d86332fb47 /agent-note-browser-excel-preview-with-saved-formula-results/alternatives-considered: - en: 0747bca32af26bfc - zh: 1d2739208e7c7d7c + en: 91a98713cd1316be + zh: 2a27af0f5b8a5e46 /agent-note-browser-excel-preview-with-saved-formula-results/consequences: en: 62595b9a9b948422 zh: 0618904d9ebfa7fc diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 435d7e4f46..38c918bdc2 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -36,7 +36,7 @@ The core patch also skips drawing freeze dividers when `allowEdit === false`, re **Replace ExcelJS or require users to rewrite their files.** A parser replacement puts retained fonts, borders, merges, and frozen panes at risk. Rewriting a source file makes a viewer defect the user's responsibility. A temporary preview copy preserves both the existing adapter and the user's file. -**Keep PDF as an Excel fallback or calculate every formula on import.** PDF retains neither spreadsheet interaction nor formula inspection, while recalculation can change saved results. The [Office engine reference](../../../../packages/document/office-to-pdf/README.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets. +**Keep PDF as an Excel fallback or calculate every formula on import.** PDF retains neither spreadsheet interaction nor formula inspection, while recalculation can change saved results. The [Office engine reference](../../../../packages/document/office-kit/README.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index e4dbd8843a..031b30d31c 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -36,7 +36,7 @@ core 补丁还在 `allowEdit === false` 时跳过冻结分隔线的绘制,保 **替换 ExcelJS 或要求用户重写文件。** 更换解析器会使现有字体、边框、合并及冻结窗格的保留面临风险。重写源文件则把查看器缺陷交给用户处理。临时预览副本同时保留现有适配器和用户文件。 -**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎参考](../../../../packages/document/office-to-pdf/README.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 +**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎参考](../../../../packages/document/office-kit/README.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml new file mode 100644 index 0000000000..79b2e5d678 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-26-manual-generation-continuation.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-26-manual-generation-continuation.md +/agent-note-manual-generation-continuation: + en: 327aaa1cdc63b1f5 + zh: 1147b4a23e51772c +/agent-note-manual-generation-continuation/problem: + en: f118844946afdbdc + zh: f1fe37d042f88b24 +/agent-note-manual-generation-continuation/decision: + en: 463db7deb7710e07 + zh: cfacb69cb58349a9 +/agent-note-manual-generation-continuation/alternatives-considered: + en: 58bb948d210d42ea + zh: 5dc920d222d71218 +/agent-note-manual-generation-continuation/consequences: + en: e86aee3a7da7649f + zh: d58e8cc94e5008bf +/agent-note-manual-generation-continuation/verification: + en: e31a5bd5826c689f + zh: 02ac700146246664 diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md new file mode 100644 index 0000000000..dd7a0e24f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md @@ -0,0 +1,33 @@ +# Agent Note: Manual generation continuation + +Status: implemented + +English | [中文](2026-10-26-manual-generation-continuation.zh.md) + +## Problem + +Users need another request after a model failure without inserting an artificial instruction. Browser paging can omit the failed turn's start, and streaming must not repeatedly copy history. + +## Decision + +The play action submits `content: []` through the existing Session prompt API. The API assigns `source.kind: 'retry'`; unchanged AgentLoop admission records an empty `user/message` and opens a normally balanced new turn. Chat presents a compact continuation-request receipt while the log retains the empty input. The original failed turn remains closed. + +The Session API owns the fixed-size `conversationContinuation` projection and shares its value with the browser. Admission requires an eligible failure, true idle ownership, and no pending human input. An appended empty retry message admits the new turn, preserving eligibility if its request also fails. + +The six-code manual allowlist stays independent of provider retry policy. Non-waking injected context accompanies the request. The accepted user message retains request-id deduplication after the inbox input is claimed. Reusing ordinary message admission avoids a Loop branch and a separate durable acceptance event. + +## Alternatives considered + +**Localized or hidden continuation text.** Adds model-visible instructions and changes retained history. + +**A separate resume operation.** The existing prompt and inbox already carry identified control input and own wakeup. + +**Keep the failed turn waiting for a click.** Changes failure settlement, idle status, cancellation, and model-selection semantics. + +## Consequences + +Ordinary empty-draft Enter remains inert. The explicit action reuses submission state and error reporting without an echo or generated draft. Pre-step rejection and cleared batches still prevent model calls. + +## Verification + +Unit and assembled scenarios cover repeated failure, request deduplication, injected context, balanced turns, retained tools, one original user message, and zero Client event-window reads. diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md new file mode 100644 index 0000000000..dc23da7759 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 手动继续生成 + +Status: implemented + +[English](2026-10-26-manual-generation-continuation.md) | 中文 + +## 问题 + +模型请求失败后,用户需要再次请求,但不应插入人为构造的指令。浏览器分页可能缺少失败轮次起点,流式输出也不能反复复制历史。 + +## 决策 + +播放按钮通过现有 Session prompt API 提交 `content: []`。API 指定 `source.kind: 'retry'`;AgentLoop 沿用普通准入,记录空 `user/message` 并开启正常闭合的新轮次。Chat 显示轻量的继续请求记录,日志保留空输入。原失败轮次保持闭合。 + +Session API 拥有固定大小的 `conversationContinuation` 投影,向浏览器发布相同的值。准入要求失败符合条件、持有真正的空闲状态且没有待消费的用户输入。追加的空重试消息使新轮次获得准入,因此请求再次失败后仍保留继续资格。 + +六码手动白名单独立于提供方重试策略。非唤醒的注入上下文随请求进入。inbox 输入被领取后,已接受的用户消息仍支持 request-id 去重。复用普通消息准入避免增加 Loop 分支及独立持久接受事件。 + +## 考虑过的替代方案 + +**本地化或隐藏的继续文本。** 增加模型可见指令并改变保留历史。 + +**单独的 resume 操作。** 现有 prompt 和 inbox 已可承载带身份的控制输入并拥有唤醒流程。 + +**保持失败轮次等待点击。** 改变失败结算、空闲状态、取消和模型选择语义。 + +## 后果 + +普通空草稿回车仍不执行。显式继续沿用提交状态与错误报告,不创建消息回显或生成草稿。pre-step 拒绝和被清空的批次仍阻止模型调用。 + +## 验证 + +单元和组装场景覆盖重复失败、请求去重、注入上下文、轮次闭合、工具结果保留、仅一条原始用户消息,以及客户端不读取完整事件窗口。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 531b156371..957d8ff349 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -33,8 +33,8 @@ en: d83f6cf38529c5b5 zh: 4df5659b2154d3b7 /agent-note-private-npm-publication-as-three-independent-sequences/decision/release-family-objects: - en: 5bc6a83d124b682d - zh: 17383ec5c3c7e0e5 + en: e0cdaa5aaa1f7d25 + zh: 645f0a0e43a37ddd /agent-note-private-npm-publication-as-three-independent-sequences/decision/workflow-shape-pack-on-pr-push-publish-from-a-manual-dispatch-workflow: en: ea0acc6409da4d5c zh: ac0bd2fe41f4fe67 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index 8552232363..68b3b067a9 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -117,7 +117,7 @@ The entity in this domain is a **release family**: a set of packages sharing one | `publish` | the three registry states above | | `process` / `tarball` | the one home for spawning commands and for reading a packed tarball, including the entry guard that keeps every script importable | -The dsh family applies the repository's publication payload policy, which rejects sources and declaration maps. The vendored family keeps upstream's payload, because those manifests export `./src/*` and dropping `src` would publish an export map pointing at absent files. +The dsh family applies the repository's publication payload policy, which publishes `src`, its declaration maps, and the JavaScript maps beside the bundles they describe ([rationale](2026-10-08-published-source-and-docs.md)). The vendored family keeps upstream's payload, because upstream's manifest owns it and those manifests export `./src/*`. ### Workflow shape: pack on PR/push, publish from a manual dispatch workflow diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index 9d130478e6..d3437eecae 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -117,7 +117,7 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 | `publish` | 上面那三态 | | `process` / `tarball` | 启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import | -dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 `./src/*`,去掉 `src` 会发出一个导出映射指向不存在文件的包。 +dsh 族套用仓库的发布 payload 策略(发布 `src`、其声明映射,以及紧挨所描述 bundle 的 JavaScript map,[理由](2026-10-08-published-source-and-docs.zh.md))。vendored 族保留上游 payload,因为该 payload 由上游 manifest 拥有,且它们导出 `./src/*`。 ### workflow 形状:PR/push 上 pack,从手动 dispatch 工作流发布 diff --git a/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.i18n.yaml b/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.i18n.yaml new file mode 100644 index 0000000000..ec6ba18f36 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-08-published-source-and-docs.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-08-published-source-and-docs.md +/agent-note-published-source-maps-and-documentation: + en: dbec3958b6bb3a8c + zh: f17abeb0533b4191 +/agent-note-published-source-maps-and-documentation/problem: + en: 699d35bd0bb12645 + zh: c9e362c336c6b440 +/agent-note-published-source-maps-and-documentation/decision: + en: 0181de5ab7314873 + zh: 6af13b56cf779d9f +/agent-note-published-source-maps-and-documentation/alternatives-considered: + en: 953acd4b427b6698 + zh: 43426e6213773943 +/agent-note-published-source-maps-and-documentation/consequences: + en: e57d7a2d3b5cbdac + zh: 1f7c314b09643ae6 diff --git a/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.md b/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.md new file mode 100644 index 0000000000..40569c32d5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.md @@ -0,0 +1,41 @@ +# Agent Note: Published source, maps, and documentation + +Status: implemented + +English | [中文](2026-10-08-published-source-and-docs.zh.md) + +## Problem + +A published `@deepseek-ai/dsh-*` package carried declarations, bundled runtime, and locale resources, but neither the TypeScript the declarations describe nor the maps that resolve into it. Its `exports` map nevertheless declared `./src/*`, so an installed consumer resolved that subpath to files the tarball did not contain. A plugin author who wanted the implementation behind a documented API had to read the declaration, the documentation website, or a checkout at the matching `dsh-v*` tag: the first stops at the declaration, and the other two are not the installed version. The repository's Markdown documentation reached readers only through the website, which tracks the published release. + +## Decision + +Every release member publishes its TypeScript source, the declaration maps that resolve into it, and the JavaScript maps beside the bundles they describe. `@deepseek-ai/dsh` publishes the documentation website's page set plus the upgrade guides, and the build compresses emitted bundles without mangling identifiers. + +[`expectedDshPackageFiles`](../../../../scripts/check-workspace-constraints.ts) lists `lib/types/**/*.d.ts.map`, `lib/**/*.js.map`, and `src`; a package with Typert Remote navigation also lists `lib/typert.remote-client.d.ts.map`, which its generated declaration references through `sourceMappingURL`. No payload member is forbidden, so the static manifest check owns the exact publication list. The `./src/*` export that 315 of the 332 release members declare resolves to those files, which serves editor navigation and reading; plain Node still cannot import them without a TypeScript loader. + +The client preset compresses the browser bundle's text (`minify: { compress: false, mangle: false }`). Identifiers keep their names, so a stack frame and the published map still name the original code. Node-side entries stay uncompressed: the browser worker image lowers `yield*` only as a whole statement and rewrites `for await` loops by their source layout, and compressed input breaks both. + +`@deepseek-ai/dsh` stages that set into `apps/cli/docs` from its `prepack` lifecycle through [`scripts/stage-dsh-docs.ts`](../../../../scripts/stage-dsh-docs.ts), and removes the copy again from `postpack`. The set is the website manifest's Markdown, every `docs/upgrade-guide/` document, and the non-Markdown assets those pages reference. Staging also generates `docs/upgrade-guide/README.md`, an index grouped by the release each `v` directory upgrades from, with each guide's title, its English and Chinese descriptions, and both relative paths; the repository tree keeps no index of its own. The generated `docs/persistence-changes/` records, the `docs/i18n/` guidelines, and the `*.i18n.yaml` consistency records stay out. The vendored family keeps upstream's payload unchanged ([release sequences](2026-08-10-npm-release-sequences.md)). + +Desktop keeps `src/` and the `@deepseek-ai/dsh` `docs/` tree inside `app.asar`, so an agent reads the installed version's documentation and code there; [its runtime file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) still omits every declaration and map. Ordinary tools cannot open an archive entry, so [`dsh-shell-env`](../../../../packages/shell/shell-env/README.md) gives every model shell call `DSH_INSTALL_DIR`, the `@deepseek-ai/dsh` package directory, and `DSH_NODE_EXECUTABLE`, the executable running the Harness; under Desktop that is Electron, whose Node mode (`ELECTRON_RUN_AS_NODE=1`) reads the archive. The Python single-file executable pays nothing: [`PUBLISHED_PAYLOAD_IGNORES`](../../../../scripts/build-exe-for-python-sdk.ts) excludes the same trees, so its contents do not depend on a staged `docs` left in the checkout. + +## Alternatives considered + +**Keep the payload declarations-only and send readers to the documentation website or the matching `dsh-v*` tag.** Rejected because neither is the installed version, and the website serves the latest published release rather than a prerelease. The declared `./src/*` export also has no meaning without those files. + +**Publish maps without `src`.** Rejected because a map whose `sources` are absent resolves nothing, which is exactly the navigation the [Typert Remote design](../architecture/2026-08-02-typert-remote-method-calls.md) requires. + +**Publish the whole `docs/` tree, or only the website's page set.** Both extremes were rejected. The whole tree publishes maintainer material an installed reader does not need — postmortems, testing guides, agent instructions, and the generated migration records — while the site's page set alone omits the upgrade guides, which are the documents an installed reader consults when a released change breaks something. + +**Mangle the bundles as well.** Rejected because mangled identifiers break the correspondence a published map exists to provide and make an unhandled stack frame unreadable. + +**Compress the node-side entries as well.** Rejected because the browser worker image refuses compressed input: the packer rejects a `yield*` a compressor moved into an expression, and it produces unparseable code for `dsh-agent-instructions`. Both are lowering constraints, not size arguments. + +## Consequences + +`src` adds about 29 MB and declaration maps about 10 MB across the release set; JavaScript maps add about 49 MB, and the staged documentation adds 6.4 MB across 320 files to `@deepseek-ai/dsh`. Compression removes about a third of a browser bundle — `ui-chat`'s `lib/client.js` falls from 591 kB to 420 kB — while node-side entries keep their size until the worker image lowering accepts compressed input. The macOS arm64 `app.asar` grows by about 20 MB of `src` and 5 MB of documentation; the Python executable keeps its previous payload. + +A consumer editor now follows a declaration to the installed implementation, and a plugin author reads that version's documentation without a checkout. The staged copy keeps the repository's relative links, so a link into `../packages/**` or into the omitted `persistence-changes/` records resolves only in a checkout. `docs/` exists in the working tree only between `prepack` and `postpack`, and `pnpm run clean` removes a copy an interrupted pack left behind. + +`pnpm run constraints` rejects a member whose `files` omits `src` or a map, because the canonical list is exact. The release family's tarball check requires `@deepseek-ai/dsh` to carry documentation, the upgrade guide index, and source, so a pack run that skips the staging hook fails instead of publishing without them. diff --git a/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.zh.md b/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.zh.md new file mode 100644 index 0000000000..b51b14a48e --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-08-published-source-and-docs.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 发布源码、映射与文档 + +Status: implemented + +[English](2026-10-08-published-source-and-docs.md) | 中文 + +## Problem + +已发布的 `@deepseek-ai/dsh-*` 包带有声明文件、打包后的运行时和 locale 资源,却没有声明文件所描述的 TypeScript,也没有能解析到它的映射。它的 `exports` 仍然声明了 `./src/*`,于是已安装的消费方把该子路径解析到 tarball 中并不存在的文件。插件作者若想看到某个已记录 API 背后的实现,只能读声明文件、文档网站,或检出匹配的 `dsh-v*` tag:第一种停在声明处,后两者都不是已安装的版本。仓库的 Markdown 文档也只能通过网站到达读者,而网站跟随的是已发布版本。 + +## Decision + +每个发布成员都发布自己的 TypeScript 源码、能解析到该源码的声明映射,以及紧挨所描述 bundle 的 JavaScript map。`@deepseek-ai/dsh` 另发布文档网站的页面集合外加升级指南,构建也会压缩产出的 bundle,但不混淆标识符。 + +[`expectedDshPackageFiles`](../../../../scripts/check-workspace-constraints.ts) 列出 `lib/types/**/*.d.ts.map`、`lib/**/*.js.map` 与 `src`;带 Typert Remote 导航的包还会列出 `lib/typert.remote-client.d.ts.map`,其生成的声明通过 `sourceMappingURL` 引用该映射。没有任何载荷成员被禁止,发布清单由静态 manifest 检查精确持有。332 个发布成员中已有 315 个声明过 `./src/*`,该导出现在解析到这些文件,服务于编辑器导航与阅读;纯 Node 仍需 TypeScript loader 才能 import 它们。 + +client 预设会压缩浏览器 bundle 的文本(`minify: { compress: false, mangle: false }`)。标识符保持原名,因此栈帧与已发布的 map 仍然指向原始代码。Node 侧入口不压缩:浏览器 worker 镜像只把 `yield*` 当作整条语句下沉,并按源码版式重写 `for await` 循环,压缩后的输入会同时破坏这两者。 + +`@deepseek-ai/dsh` 通过 [`scripts/stage-dsh-docs.ts`](../../../../scripts/stage-dsh-docs.ts) 在自己的 `prepack` 生命周期里把这一集合复制到 `apps/cli/docs`,并在 `postpack` 中再次删除这份副本。集合包含:网站清单中的 Markdown、`docs/upgrade-guide/` 下的全部文档,以及这些页面引用的非 Markdown 资产。暂存时还会生成 `docs/upgrade-guide/README.md`:按每个 `v` 目录升级前的版本分组,列出每份指南的标题、中英文描述与两种语言的相对路径;仓库树本身不保留索引。生成的 `docs/persistence-changes/` 记录、`docs/i18n/` 规范与 `*.i18n.yaml` 一致性记录不发布。vendored 族保持上游 payload 不变([发布序列](2026-08-10-npm-release-sequences.zh.md))。 + +桌面版在 `app.asar` 内保留 `src/` 与 `@deepseek-ai/dsh` 的 `docs/` 树,agent 可在其中读取已安装版本的文档与代码;其[运行时文件策略](../../../../apps/desktop/scripts/runtime-file-policy.ts)仍剔除全部声明文件与映射。普通工具打不开归档内条目,因此 [`dsh-shell-env`](../../../../packages/shell/shell-env/README.zh.md) 为每次模型 shell 调用提供 `DSH_INSTALL_DIR`(`@deepseek-ai/dsh` 包目录)与 `DSH_NODE_EXECUTABLE`(运行 Harness 的可执行文件);桌面版下它是 Electron,其 Node 模式(`ELECTRON_RUN_AS_NODE=1`)能读取归档。Python 单文件可执行体不付出代价:[`PUBLISHED_PAYLOAD_IGNORES`](../../../../scripts/build-exe-for-python-sdk.ts) 排除同样的树,因此其内容不取决于检出中是否残留暂存的 `docs`。 + +## Alternatives considered + +**保持只发声明文件,把读者引向文档网站或匹配的 `dsh-v*` tag。** 否决:两者都不是已安装的版本,而网站提供的是最新已发布版本而非预发布版本。声明的 `./src/*` 导出没有这些文件也毫无意义。 + +**只发布映射,不发布 `src`。** 否决:`sources` 不存在的映射解析不到任何东西,而这正是 [Typert Remote 设计](../architecture/2026-08-02-typert-remote-method-calls.zh.md)所要求的导航。 + +**整树发布,或只发布网站页面集合。** 两个极端都否决。整树会把已安装读者并不需要的维护者材料发出去——事故复盘、测试指南、Agent 指令与生成的迁移记录;而只发网站页面集合会漏掉升级指南,也就是已发布改动造成破坏时读者真正要查的文档。 + +**连标识符一起混淆。** 否决:混淆会破坏已发布 map 本应提供的对应关系,并使未处理栈帧无法阅读。 + +**连 Node 侧入口一起压缩。** 否决:浏览器 worker 镜像拒绝压缩后的输入——打包器会拒绝被压缩器挪进表达式的 `yield*`,并且对 `dsh-agent-instructions` 产出无法解析的代码。这两条都是下沉变换的限制,不是体积上的理由。 + +## Consequences + +`src` 在整个发布集上增加约 29 MB,声明映射增加约 10 MB,JavaScript map 增加约 49 MB,暂存的文档以 320 个文件给 `@deepseek-ai/dsh` 增加 6.4 MB。压缩让浏览器 bundle 约减少三分之一——`ui-chat` 的 `lib/client.js` 从 591 kB 降到 420 kB——而 Node 侧入口在 worker 镜像接受压缩输入之前保持原体积。macOS arm64 的 `app.asar` 增加约 20 MB `src` 与 5 MB 文档;Python 可执行体保持原有载荷。 + +消费方编辑器现在可以从声明跳转到已安装的实现,插件作者无需检出即可读到该版本的文档。暂存副本保留仓库的相对链接,因此指向 `../packages/**` 或指向被排除的 `persistence-changes/` 记录的链接只在检出中可解析。`docs/` 只在 `prepack` 与 `postpack` 之间存在于工作树,`pnpm run clean` 会清掉被中断的打包留下的副本。 + +`pnpm run constraints` 会拒绝 `files` 中缺少 `src` 或任一声明映射的发布成员,因为该清单是精确匹配的。发布族的 tarball 校验要求 `@deepseek-ai/dsh` 必须带上文档、升级指南索引与源码,因此跳过暂存钩子的打包会直接失败,而不是发出一个缺少它们的包。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml index ce51330f2a..7f183184ef 100644 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml @@ -15,8 +15,8 @@ en: 403bd786a8411349 zh: c44584e8a7e5b74a /agent-note-deferred-desktop-update-extensions/proposal/stable-subscription-and-channel-switching: - en: f39b1ed0d941d372 - zh: 05b9a006c9ec0d3c + en: 33663c160d5e280b + zh: 96689283c31ed820 /agent-note-deferred-desktop-update-extensions/proposal/automatic-installation-setting: en: 758d49f0a7dc6707 zh: ddb0bfe616eb93bc diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md index 6c8a2ebe4a..a5fad6dbd5 100644 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md @@ -29,16 +29,7 @@ Do not use `0.1.3-dsk.1` to repair stable `0.1.3`: SemVer orders it below the st ### Stable subscription and channel switching -| User option | Feed | Release contents | -|---|---|---| -| Stable | `latest.yml` / `latest-mac.yml` | Stable only | -| Nightly | `nightly.yml` / `nightly-mac.yml` | Alpha, rc, and stable | - -The [initial publication rule](2026-09-08-desktop-update-policy-and-installation.md) adds the stable feed when shared stable releases exist, without switching fixed-Nightly clients. A user-facing stable subscription and selector remain deferred. If enabled, persist the selected channel; never infer it from the installed version. Preserve `allowDowngrade = false` when assigning the updater channel. Moving from Nightly to stable keeps a higher installed Nightly until a higher stable version exists, without downgrading Desktop or bundled dsh. - -Channel switching may immediately check; download behavior must follow the approved setting. Ignore old-channel responses and reconfirm downloaded artifacts against the new channel before installation. A channel change does not clear mandatory blocking without a valid policy response for the new conditions. Display a persistent Nightly explanation under the selector, with candidate Chinese copy “提前体验新功能,版本可能不稳定。”; hide it after selecting stable, while separately explaining any wait for a higher stable version. - -Design compatibility only for Desktop packages actually distributed. If a future migration has real clients reading `alpha.yml` or `rc.yml`, publish a higher bridge version to the old feed and Nightly until the old entry can be retired. No such bridge is required initially. +This part of the proposal is superseded by [Desktop channel settings](../../../../apps/desktop/README.md#local-desktop-settings) and the [packaging channel policy](../../../../apps/desktop/packaging/README.md). The implemented selector persists explicit user choices; publishers select versions independently of channel names, and channel changes do not permit downgrades. ### Automatic-installation setting diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md index 06096c8b28..2b7ca7ea52 100644 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md @@ -29,16 +29,7 @@ Status: proposed ### 稳定版订阅与通道切换 -| 用户选项 | Feed | 发布内容 | -|---|---|---| -| 稳定版 | `latest.yml` / `latest-mac.yml` | 仅稳定版本 | -| Nightly | `nightly.yml` / `nightly-mac.yml` | alpha、rc 和稳定版本 | - -[一期发布规则](2026-09-08-desktop-update-policy-and-installation.zh.md)在共用稳定版本出现时增加稳定 feed,不切换固定 Nightly 客户端。面向用户的稳定版订阅与选择器仍延后。启用后持久化所选通道,不从已安装版本反推。设置 updater 通道时保持 `allowDowngrade = false`。从 Nightly 切到稳定版时,较高的已安装 Nightly 持续运行,直到出现更高稳定版;不得降级 Desktop 或内置 dsh。 - -切换通道可以立即检查,下载行为必须遵循获准设置。忽略旧通道响应,安装前按新通道重新确认已下载产物。通道改变不能在缺少新条件有效策略响应时解除强制阻断。选择器下方常驻 Nightly 说明,候选中文为“提前体验新功能,版本可能不稳定。”;选择稳定版后隐藏,必要时另行说明需等待更高稳定版。 - -仅为实际分发过的 Desktop 安装包设计兼容。后续迁移若存在读取 `alpha.yml` 或 `rc.yml` 的真实客户端,应向旧 feed 与 Nightly 发布更高桥接版本,直到可淘汰旧入口。一期不需要这类桥接。 +本提案的这一部分已由[桌面渠道设置](../../../../apps/desktop/README.zh.md#local-desktop-settings)和[打包渠道策略](../../../../apps/desktop/packaging/README.zh.md)取代。已实现的选择器持久化用户的显式选择;发布者独立于渠道名称选择版本,切换渠道不允许降级。 ### 自动安装设置 diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml index 0374d8ce64..36df13a37c 100644 --- a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml @@ -6,11 +6,11 @@ en: 9966b1c4fdb2585b zh: 4eb24c283facf38d /agent-note-defer-office-background-scheduling-until-a-consumer-needs-it/problem: - en: bb725ab25ff39f86 - zh: d198514119b31a61 + en: a3e6453fd9d38eb3 + zh: accb5062ae04673e /agent-note-defer-office-background-scheduling-until-a-consumer-needs-it/proposal: - en: e33db390dbbcc86c - zh: 890df8891260103a + en: b9b3e2dd1f8b4ff5 + zh: d751ef9cdff21de9 /agent-note-defer-office-background-scheduling-until-a-consumer-needs-it/alternatives-considered: en: 597346036314c907 zh: 91661d961623c7cd diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md index 82311022a0..f7dc199487 100644 --- a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md @@ -6,15 +6,15 @@ English | [中文](2026-09-17-defer-office-background-scheduling.zh.md) ## Problem -The [conversion queue](../../../../packages/document/office-to-pdf/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits. +The [conversion queue](../../../../packages/document/office-kit/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits. -The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeToPdf`, `OfficeToPdfRequest`, `OfficeToPdfPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed. +The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeKit`, `OfficeKitRequest`, `OfficeKitPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed. The [bounded conversion decision](../../implemented/architecture/2026-09-15-bounded-office-conversion.md) deliberately reserves capacity for foreground consumers. That remains the correct rule if speculation exists, but the current caller inventory does not justify maintaining the speculative request class and its lifecycle interactions. ## Proposal -Remove `OfficeToPdfPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit. +Remove `OfficeKitPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit. Keep the provider, reusable converters, deferred authorized reads, all remaining resource limits, source-version checks, content sharing, bounded result cache, independent reader cancellation, and teardown that waits for actual work to settle. This proposal changes admission policy, not conversion ownership or engine selection. diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md index ada0146f20..36a28472b3 100644 --- a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md @@ -6,15 +6,15 @@ Status: proposed ## 问题 -[转换队列](../../../../packages/document/office-to-pdf/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。 +[转换队列](../../../../packages/document/office-kit/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。 -[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeToPdf`、`OfficeToPdfRequest`、`OfficeToPdfPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。 +[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeKit`、`OfficeKitRequest`、`OfficeKitPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。 [有界转换决策](../../implemented/architecture/2026-09-15-bounded-office-conversion.zh.md) 有意为前台消费者预留容量。存在推测性任务时,这条规则仍然正确,但当前调用方清单不足以支持维护推测性请求类别及其生命周期交互。 ## 提案 -移除 `OfficeToPdfPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。 +移除 `OfficeKitPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。 保留提供者、可复用转换器、延迟授权读取、其余所有资源限制、源版本校验、内容共享、有界结果缓存、读取方独立取消,以及等待实际工作结束的清理。这项提案改变接纳策略,不改变转换所有权或引擎选择。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 2be16611a8..406375ff61 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -22,7 +22,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. - [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures. - [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes. -- [dsh-error-handling](../dsh-error-handling/SKILL.md): failure classification, handling ownership, containment, state guarantees, and actionable reports. +- [dsh-error-handling](../dsh-error-handling/SKILL.md): failure classification, error classes and codes, tool failures, handling ownership, containment, state guarantees, and actionable reports. - [dsh-session-projection](../dsh-session-projection/SKILL.md): projection ownership, state size, historical reads, and migrations. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates reference](../../../docs/testing.md): required test tiers and gates. - [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto. @@ -41,7 +41,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **UI review coverage:** apply the [UI change workflow](../dsh-client-ui-ux/SKILL.md#required-review-loop) to the cumulative visible diff. Verify its [minimum checks](../dsh-client-ui-ux/SKILL.md#minimum-checks-for-affected-ui) for the changed properties and consumers. Missing required inspection remains a gap; missing optional reports does not. Do not demand full-audit records for ordinary changes. Recheck only new interactions or invalidated evidence. Existing tests, GUI GIFs and designer/product approval remain required under their original triggers. - **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. -- **Error handling:** apply [dsh-error-handling](../dsh-error-handling/SKILL.md) to changed failure paths, including their state guarantees and final user-visible reports. Reject catch-and-continue unless the state owner establishes that subsequent work's preconditions hold, including after recovery. +- **Error handling:** apply [dsh-error-handling](../dsh-error-handling/SKILL.md) to changed failure paths, including their state guarantees and final user-visible reports. Flag an expected tool failure that would reach `tool/result` without `error.name`/`error.code`. Reject catch-and-continue unless the state owner establishes that subsequent work's preconditions hold, including after recovery. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns/general.md](../../../docs/defensive-patterns/general.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. - **Scope, ownership, and necessity:** first identify the required behavior behind each new distinction, field, adapter, state machine, option, defensive copy, and compatibility path. A requirement, check, or Agent Note introduced by the PR is a design choice to assess, not independent evidence of necessity. Compare existing source fields, discriminants, and their consumers before accepting another representation; distinguish deleting a concept from relocating its declaration. Moving an unnecessary concept closer to its owner is not a simplification. Challenge unrelated features and speculative generality against [the root rules](../../../AGENTS.md#conventions). - **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package rules](../../../packages/AGENTS.md). For required behavior, prefer an existing extension point near its producer or consumer when the local implementation adds little complexity, following [the ownership rule](../../../docs/defensive-patterns/general.md#a-business-capability-owns-itself); compare producer-owned declaration merging with changes to distant core or shared type declarations. Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an unnecessary API expansion — require a private capability closure handed to that consumer at construction instead. diff --git a/.agents/skills/dsh-create-upgrade-guide/SKILL.md b/.agents/skills/dsh-create-upgrade-guide/SKILL.md index 9723537e22..c3442ab971 100644 --- a/.agents/skills/dsh-create-upgrade-guide/SKILL.md +++ b/.agents/skills/dsh-create-upgrade-guide/SKILL.md @@ -29,7 +29,7 @@ Pre-stable internal APIs whose consumers are all updated in the same change need ## Maintenance -While the root version is unchanged, the current version directory describes the delta from vT to current `master`. When a later change touches a surface a guide there covers, update that guide in the same change; delete the guide when the break is reverted. After the version bump, guides in older version directories are frozen; a new break goes into the new version directory. Never write a guide spanning more than one release. +While the root version is unchanged, the current version directory describes the delta from vT to current `master`. When a later change touches a surface a guide there covers, update that guide in the same change; delete the guide when the break is reverted. After the version bump, guides in older version directories are frozen; a new break goes into the new version directory. When a release lands while your pull request is open, move its guides to the new version directory: `Policy / Pre-Merge Check` runs `verify-upgrade-guides --base` on the merged tree and rejects a guide added outside the root version's directory unless the `release/dsh-` branch of that guide's version line already contains it. Never write a guide spanning more than one release. ## Format diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md index f9dd7fd809..126c9d0ecc 100644 --- a/.agents/skills/dsh-error-handling/SKILL.md +++ b/.agents/skills/dsh-error-handling/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-error-handling -description: Apply error-handling principles when designing or reviewing failures, recovery, retries, background tasks, and user-visible error reports. +description: Apply error-handling principles when designing or reviewing failures, error classes and codes, tool failures, recovery, retries, background tasks, and user-visible error reports. --- # DeepSeek Harness Error Handling @@ -11,6 +11,23 @@ Define failures, their owners, and what remains safe after failure. Expected failures need declared types, stable discriminants, and handling-relevant details. Reuse package error conventions, preserve causes, and separate machine identity from display text. Never route handling by parsing messages. Unexpected defects such as `assertNever` may remain plain `Error`; containment must never turn them into success. +An error class that carries a machine code extends `HarnessError` from `dsh-llm`, directly or through its package's error class, never bare `Error`. Prefer one error class per owning package or domain over a class per failure; a separate class is justified when callers catch it by type or it carries its own structured fields, such as `ToolArgsError.violations`. `name` is the class name, except for cancellation (below), and `code` values are stable `UPPER_SNAKE_CASE` strings, usually prefixed by the domain and declared as a union or constants beside the class. A code names the failure class its consumers act on, not the call site; add a code rather than reuse one whose meaning differs. The [error class and code guide](references/error-codes.md) defines class choice, naming, the standard conditions, declaration, foreign-error conversion, and compatibility. + +Convert foreign failures where the dependency is owned. Node errno, SDK, protocol, native, worker, and cross-realm errors become the owning package's error with the original as `cause`; a foreign numeric or vendor code belongs in the message, not in `code`. Preserve structure across layers: never re-wrap a `HarnessError` in a plain `Error`, store only its message for a later rethrow, or drop its `name` and `code` while redacting text. A process or worker boundary transports `name` and `code` and reconstructs the error. + +## Tool failures + +The tool registry keeps `error.name` and `error.code` on `tool/result` when the producer supplies them: a `HarnessError` thrown by the tool body, a pre-execute `deny` with `info`, the registry's own cancellation result (`AbortError` with `ABORTED` or `ABORTED_BEFORE_DISPATCH`), or a `tools/execute` wrapper result such as the timeout policy's `TOOL_TIMEOUT`. Any other thrown value, a guard denial, an approval rejection, and a post-execute block persist only text that retry policy, UI, and analytics cannot route on. Every expected failure reachable from a tool body therefore throws a `HarnessError`, normally the owning domain's subclass: + +- Hand-checked argument constraints throw `ToolArgsError` (`INVALID_ARGS`) from `dsh-tools`. +- Missing composition or caller context, such as an absent Agent, service, or open turn, throws the owning package's error with its own code. +- Caller cancellation throws a `HarnessError` whose `name` is `AbortError` and whose code is the `dsh-tools` `TOOL_ABORTED` constant (`ABORTED`), matching the registry's cancellation result; never a bare `AbortError` or `signal.reason`. +- Dependency failures follow the conversion rule above; filesystem errno maps to `FsError` codes such as `FS_NOT_FOUND`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR`. + +A policy that denies a call in `tools/pre-execute` supplies `info` with its own `name` and `code`. The registry's built-in approval rejections and guard denials currently carry only a reason, so they remain unroutable until the registry gives them codes. A failure the model must not treat as success is an `isError` result; a successful domain outcome in a non-ideal state, such as a non-zero exit or no matches, stays a success value, and a success-shaped failure value is allowed only when the output schema documents it. Tests assert `error.info.name` and `error.info.code` for each expected failure path, not only the message. + +Existing `name` and `code` values persist in Session logs and feed analytics. Renaming or reclassifying one is an externally perceptible change that needs an [upgrade guide](../dsh-create-upgrade-guide/SKILL.md). + ## Catch, recover, or stop For each failure, define handling and reporting owners, containment, and guarantees in the owning API documentation: valid invariants, absent/partial/committed/unknown effects, and cleanup responsibility. Background work retains these obligations. diff --git a/.agents/skills/dsh-error-handling/references/error-codes.md b/.agents/skills/dsh-error-handling/references/error-codes.md new file mode 100644 index 0000000000..26b2cada50 --- /dev/null +++ b/.agents/skills/dsh-error-handling/references/error-codes.md @@ -0,0 +1,120 @@ +# Error class and code guide + +Use this guide when adding or reviewing an error class, an error code, or a failure path whose `name` and `code` reach a durable record such as `tool/result` `error`. + +## Where `name` and `code` go + +`HarnessError` carries `name` and `code` beside `message` and `cause`. The tool registry copies `name` and `code` of a thrown `HarnessError` into `ToolErrorInfo`, which the Session log persists as `tool/result` `error`; the model sees only the rendered `message`. Retry policy, UI cards, replay, and failure analytics route on `name` and `code`, so both are compatibility promises from the first release that records them. + +| Field | Audience | Content | +|---|---|---| +| `name` | Code and analytics | The error class; groups every failure one domain owns | +| `code` | Code and analytics | The failure class a consumer acts on | +| `message` | Model and logs | English, actionable, names the operation and the safe next step | +| `reason` (`ToolErrorInfo`) | User-facing projections | Optional raw detail kept out of model-facing content; only a policy decision that supplies `info` can set it | +| `cause` | Debugging | The original foreign or lower-level error | + +## Choose the class + +1. Reuse the owning domain's existing class when the failure belongs to that domain: `FsError` for filesystem targets, `SearchError` for `glob`/`grep`, `LspError`, `WebError`, `SubagentError`, `TeamError`, `GoalError`, `WorkflowError`, `UserQuestionError`, `SessionQueryError`, the `Sandbox*Error` classes, and `CodeRunFailedError`. +2. Use `ToolArgsError` (`INVALID_ARGS`) for argument constraints the parameter schema cannot express, such as an empty string, a range, or a cross-field rule. +3. Declare a new class only for a new owning domain. Prefer one class with a code union over one class per failure; a separate subclass is justified when consumers must catch it by type or it carries its own structured fields, such as `ToolArgsError.violations`, and it then extends the domain class when one exists so the domain still matches with `instanceof`. +4. Never declare a coded class that extends bare `Error`. An existing one is migrated by changing its base class and keeping every `name` and `code`. + +## Name the class + +- `name` is the PascalCase class name ending in `Error`, such as `JobError` or `SandboxEscalationError`. +- Assign `this.name` a string literal in the constructor. `HarnessError` defaults `name` to `new.target.name`, but a bundler may rename a colliding class binding, and a renamed class would change persisted data. +- Do not reuse another domain's class name, and do not encode a code in the name. +- Cancellation is the one exception: a tool throws a `HarnessError` whose `name` is `AbortError` and whose code is the `dsh-tools` `TOOL_ABORTED` constant (`ABORTED`), so tool-thrown and registry-generated cancellations share one `name` and `code`. + +## Format the code + +- A code matches `^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$`: ASCII `UPPER_SNAKE_CASE`. +- New codes start with the domain prefix shared by the class, followed by the condition: `_`, such as `FS_NOT_FOUND`, `TEAM_TASK_STALE_REVISION`, or `MCP_CONNECTION_CLOSED`. A subject between the prefix and the condition is allowed when the domain has several subjects, as in `TEAM_TASK_*` and `TEAM_MEMBER_*`. +- Existing unprefixed codes, such as `SubagentError`'s `NOT_RESUMABLE` or `LlmError`'s `TIMEOUT`, stay as they are; renaming them is a breaking change. New codes in those classes follow the class's existing style. +- A code is a literal. Never build one from a template string, a status number, a path, an id, a tool name, or a foreign code. + +## Name the condition + +Pick the condition from what the consumer does next, not from where the failure happened. Reuse these standard conditions before inventing one: + +| Condition | Meaning | Typical consumer action | +|---|---|---| +| `INVALID_` or `INVALID_ARGUMENT` | The caller's input violates a documented constraint | The model corrects the input | +| `NOT_FOUND` | The named target does not exist | The model picks another target | +| `ALREADY_EXISTS` | Creation conflicts with an existing target | The model reuses or renames | +| `PERMISSION_DENIED` | The host or the platform refused access | Report; do not retry unchanged | +| `SANDBOX_DENIED` | Sandbox policy refused the operation | Request escalation or stop | +| `REJECTED` | A person or a review policy declined the request | Stop and explain; do not work around it | +| `CANCELLED` / `ABORTED` | The user or the caller cancelled the operation | Stop without retrying | +| `TIMEOUT` | A deadline elapsed and the outcome is known to be incomplete | Retry only when the operation is idempotent | +| `UNAVAILABLE` | A required service, provider, or backend is not present or not reachable | Report the missing composition or retry later | +| `UNSUPPORTED_` | The request is valid but this implementation cannot serve it | Choose another option | +| `LIMIT_REACHED` / `TOO_LARGE` | A configured count, size, or rate bound was hit | Reduce the request | +| `STALE_` | The caller acted on an outdated version | Re-read and retry | +| `CONFLICT` | Concurrent or duplicate work holds the target | Wait or pick another target | +| `CORRUPT` | Persisted state failed validation | Report; do not trust the state | +| `_FAILED` | A dependency failed in a way the owner cannot classify further | Report with the cause | + +Use `_FAILED` only after the specific conditions above do not fit. One code covers one consumer action: split a code when consumers must act differently, and merge candidates that every consumer handles alike. + +## Declare the codes + +A domain that owns all of its codes declares a closed list beside the class and types the constructor with it, as in this example for a job domain: + +```ts +/** Every machine-routable job failure. */ +export const JOB_ERROR_CODES = [ + 'JOB_NOT_FOUND', + 'JOB_FOREIGN_SESSION', + 'JOB_LIMIT_REACHED', +] as const + +/** One job failure code. */ +export type JobErrorCode = typeof JOB_ERROR_CODES[number] + +/** Typed job failure; `code` is one of {@link JOB_ERROR_CODES}. */ +export class JobError extends HarnessError { + override readonly code: JobErrorCode + + constructor(message: string, code: JobErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'JobError' + this.code = code + } +} +``` + +A Service Definition whose providers may add codes, such as `WebError` or `LlmError`, keeps `code: string`, documents the shared codes with exported constants, and requires consumers to tolerate unknown codes. Document each code in the class JSDoc or the owning subsystem page with its meaning and the consumer action. + +## Convert foreign failures + +Convert at the package that owns the dependency, keep the original as `cause`, and put any foreign numeric or vendor code in `message`, never in `code`. + +| Foreign failure | Converted error | +|---|---| +| `ENOENT`, `ENOTDIR` | `FsError` `FS_NOT_FOUND` | +| `EACCES`, `EPERM` | `FsError` `FS_PERMISSION_DENIED` | +| `EISDIR` where a file is required | `FsError` `FS_NOT_REGULAR_FILE` | +| Other filesystem errno | `FsError` `FS_IO_ERROR` | +| Process spawn `ENOENT` | The spawning domain's `*_NOT_FOUND`, such as a missing executable | +| JSON-RPC, HTTP, or SDK status | The domain's condition for that status, with the status in `message` | +| Native or Win32 API failure | The calling domain's `_FAILED`, with the API name and code in `message` | +| Worker or process boundary | Transport `name` and `code`, then reconstruct the same class | + +When an error passes through several layers, the lowest layer that understands the failure classifies it and the layers above preserve it. Wrap a `HarnessError` only to add context, and keep its `name` and `code` on the wrapper. + +## Change existing codes + +Adding a code or a class is compatible. Renaming, splitting, merging, or reclassifying an existing `name` or `code` changes persisted data and analytics: record it in an [upgrade guide](../../dsh-create-upgrade-guide/SKILL.md) and list the old and new values. Do not reuse a retired code for a different meaning. + +## Checklist + +- [ ] Every expected failure reachable from the changed path throws a `HarnessError` subclass. +- [ ] The class extends `HarnessError` or the domain class and assigns a literal `name`. +- [ ] Each code is a literal from the declared list and names a consumer action. +- [ ] Foreign errors are converted at their owner with `cause`, and no layer drops `name` or `code`. +- [ ] `message` is actionable for the model and carries no sensitive internal detail. +- [ ] Tests assert `name` and `code` for each expected failure. +- [ ] A changed existing `name` or `code` has an upgrade guide. diff --git a/.agents/skills/dsh-session-projection/SKILL.md b/.agents/skills/dsh-session-projection/SKILL.md index f2820d7454..1384fdb128 100644 --- a/.agents/skills/dsh-session-projection/SKILL.md +++ b/.agents/skills/dsh-session-projection/SKILL.md @@ -17,7 +17,7 @@ Keep recoverable session facts in Projection and expose them through their ownin 3. State recovery through `init` and `apply` is synchronous and deterministic. Do not perform I/O or trigger Cordis hooks, models, or tools. Runtime hooks own those effects. Replay must not repeat them. Return the same state reference for unrelated events. Keep views synchronous and preserve their references when visible values do not change. -4. Persist JSON state that its schema accepts. Follow the README's state-version and checkpoint-watermark rules. Restoring a checkpoint and replaying its suffix must match full replay. Change the version when state fields or recovery semantics change. +4. Persist JSON state that its schema accepts. Follow the README's state-version and checkpoint-watermark rules. Restoring a checkpoint and replaying its suffix must match full replay. Change the version when state fields or recovery semantics change, except for optional additions that preserve historical meaning when absent and have tested old-checkpoint suffix equivalence with full replay. Schema acceptance alone is insufficient. 5. Prefer fixed-size or sublinear state. Growth is acceptable only when slow enough to remain negligible. Necessary linear state must have a current use and explicit memory, replay, and per-event costs. Do not retain history-sized indexes, including event sequence collections, by habit. Do not discard required data or change behavior to reduce size. Start with a complete implementation; optimize for a concrete need. diff --git a/.github/AGENTS.md b/.github/AGENTS.md index c69f322610..733b45dac2 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,6 +4,8 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. -The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. +The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node with the merged base commit in `BASE_SHA` and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, rejects upgrade guides added outside the root version's directory, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. The manual Desktop publication workflow selects the original successful `package` job attempt independently of `upload-artifact` archival status, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. + +`Publish Desktop · Rollout & Website` reuses the same original-machine routing and approval Environments. Its preparation reads retained metadata and initial publication evidence without revalidating package binaries; it does not create release tags. Cache-only recovery uses the existing website approval and upload journal. diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index ff756d1b95..32efedf65c 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -9,6 +9,11 @@ on: description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包' type: string required: true + staging_percentage: + description: '更新灰度比例:0–100 整数,默认 100;留空沿用包内值,作用于原包所属渠道' + type: string + required: false + default: '100' upload_website: description: '同时上传官网安装包并刷新缓存(仅 production);取消后仅执行版本与自动更新发布' type: boolean @@ -66,6 +71,7 @@ jobs: website-destination: ${{ steps.check.outputs.website-destination }} label: ${{ steps.check.outputs.label }} env: + DESKTOP_STAGING_PERCENTAGE: ${{ inputs.staging_percentage }} DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }} DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} @@ -111,6 +117,7 @@ jobs: cancel-in-progress: false timeout-minutes: 90 env: + DESKTOP_STAGING_PERCENTAGE: ${{ inputs.staging_percentage }} DESKTOP_APPROVED_PACKAGE: ${{ needs.prepare.outputs.approval }} DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/desktop-rollout.yml b/.github/workflows/desktop-rollout.yml new file mode 100644 index 0000000000..13549da6b4 --- /dev/null +++ b/.github/workflows/desktop-rollout.yml @@ -0,0 +1,263 @@ +name: Publish Desktop · Rollout & Website + +run-name: Rollout Desktop package ${{ inputs.package_run_id }} / attempt ${{ inputs.package_run_attempt }} · ${{ inputs.rollout_percentage && format('{0}%', inputs.rollout_percentage) || 'website only' }} + +on: + workflow_dispatch: + inputs: + package_run_id: + description: '已经完成首次 COS 发布的 Package Desktop run ID' + type: string + required: true + rollout_percentage: + description: '目标放量比例:0–100 整数,只允许保持或增加;留空只发布官网' + type: string + required: false + default: '' + upload_website: + description: '上传已发布版本的官网安装包并刷新缓存(仅 production,不要求放量达到 100%)' + type: boolean + default: false + required: true + package_run_attempt: + description: '已发布的原打包 attempt;默认 1,请核对原 run' + type: string + required: true + default: '1' + +permissions: + contents: read + actions: read + +jobs: + route: + runs-on: ubuntu-latest + outputs: + selection: ${{ steps.route.outputs.selection }} + runner: ${{ steps.route.outputs.runner }} + steps: + - name: Require master for publication + run: node -e "if (process.env.GITHUB_REF !== 'refs/heads/master') throw new Error('Desktop publication must be dispatched from master')" + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Reject debugging packages before dependency installation + id: preflight + env: + GH_TOKEN: ${{ github.token }} + run: node apps/desktop/scripts/preflight-desktop-publish.mjs + - uses: pnpm/action-setup@v5.0.0 + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + - name: Resolve original package and machine + id: route + env: + GH_TOKEN: ${{ github.token }} + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + DESKTOP_PACKAGE_ATTEMPT: ${{ steps.preflight.outputs.attempt }} + run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts + + prepare: + needs: route + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + timeout-minutes: 30 + outputs: + route: ${{ steps.local.outputs.route }} + preset: ${{ steps.local.outputs.preset }} + approval: ${{ steps.check.outputs.approval }} + website-approval: ${{ steps.check.outputs.website-approval }} + website-destination: ${{ steps.check.outputs.website-destination }} + label: ${{ steps.check.outputs.label }} + env: + DESKTOP_ROLLOUT_PERCENTAGE: ${{ inputs.rollout_percentage }} + DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: desktop-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: desktop-publisher/package.json + dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration before dependency installation + working-directory: desktop-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Resolve retained package and approval environment + id: local + working-directory: desktop-publisher + env: + GH_TOKEN: ${{ github.token }} + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts route + - name: Verify initial publication and metadata without upload credentials + id: check + working-directory: desktop-publisher + env: + DESKTOP_PUBLISH_ROUTE: ${{ steps.local.outputs.route }} + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts check + + publish: + name: Approve ${{ needs.prepare.outputs.label }} + needs: [route, prepare] + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: ${{ needs.prepare.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }} + concurrency: + group: desktop-publish-${{ needs.route.outputs.runner }}-${{ needs.prepare.outputs.preset }} + cancel-in-progress: false + timeout-minutes: 90 + env: + DESKTOP_ROLLOUT_PERCENTAGE: ${{ inputs.rollout_percentage }} + DESKTOP_ROLLOUT_APPROVAL: ${{ needs.prepare.outputs.approval }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: desktop-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: desktop-publisher/package.json + dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration before dependency installation + working-directory: desktop-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Verify initial publication and metadata without upload credentials + id: check + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts check + - name: Apply rollout to the published feed + id: publish + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts apply + - name: Retain publication audit + continue-on-error: true + if: always() && steps.publish.outputs.record != '' + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publication-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.publish.outputs.record }}/ + if-no-files-found: error + retention-days: 30 + + upload-website-installer: + needs: [route, prepare, publish] + if: inputs.upload_website && needs.prepare.outputs.preset != 'test' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-upload-${{ needs.prepare.outputs.website-destination }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DESKTOP_ROLLOUT_APPROVAL: ${{ needs.prepare.outputs.approval }} + DESKTOP_ROLLOUT_PERCENTAGE: ${{ inputs.rollout_percentage }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Upload website installer + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts website-upload + - name: Archive website feed verification + if: always() && steps.website.outputs.rollout-record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-feed-verification-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.rollout-record }}/ + if-no-files-found: warn + retention-days: 30 + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-upload-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 + + purge-website-installer: + needs: [route, prepare, upload-website-installer] + if: inputs.upload_website && needs.prepare.outputs.preset != 'test' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-purge-${{ needs.prepare.outputs.website-destination }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Recover and observe website cache purge + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts website-purge + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-purge-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index 3fdb193807..5bf1b2f484 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -15,15 +15,15 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' + description: '升级通道:人工选择 nightly 或 stable,与版本后缀无关;默认通道由此打包输入确定' type: choice options: [nightly, stable] default: nightly required: true version_mode: - description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' + description: '版本模式:default 按环境自动选择(test 取 build-number,production 取 follow-dsh);follow-dsh 跟随 dsh 包版本号;build-number 自动追加构建编号;manual 手填' type: choice - options: [default, product, auto, manual] + options: [default, follow-dsh, build-number, manual] default: default required: true build_version: @@ -75,7 +75,8 @@ jobs: PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }} PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} - PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + # TODO: Remove this legacy source_ref mode mapping on 2027-01-04 with the parser aliases. + PACKAGE_VERSION_MODE: ${{ inputs.version_mode == 'follow-dsh' && 'product' || inputs.version_mode == 'build-number' && 'auto' || inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} @@ -187,6 +188,7 @@ jobs: timeout-minutes: 30 env: DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_ARCHIVE_NAME: macos-x64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} steps: @@ -209,17 +211,17 @@ jobs: echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile working-directory: artifact-archiver - - name: Verify original retained build before GitHub archival + - name: Archive verified retained build + id: archive working-directory: artifact-archiver run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain installers for the selected mode if: success() uses: actions/upload-artifact@v7.0.1 with: - name: macos-x64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} - include-hidden-files: true - path: ${{ needs.package.outputs.directory }}/* - compression-level: 0 + name: ${{ env.DESKTOP_ARCHIVE_NAME }} + path: ${{ steps.archive.outputs.path }} + archive: false if-no-files-found: error retention-days: 14 overwrite: true diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index f6f9f97401..7b9022bbfb 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -15,15 +15,15 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' + description: '升级通道:人工选择 nightly 或 stable,与版本后缀无关;默认通道由此打包输入确定' type: choice options: [nightly, stable] default: nightly required: true version_mode: - description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' + description: '版本模式:default 按环境自动选择(test 取 build-number,production 取 follow-dsh);follow-dsh 跟随 dsh 包版本号;build-number 自动追加构建编号;manual 手填' type: choice - options: [default, product, auto, manual] + options: [default, follow-dsh, build-number, manual] default: default required: true build_version: @@ -75,7 +75,8 @@ jobs: PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }} PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} - PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + # TODO: Remove this legacy source_ref mode mapping on 2027-01-04 with the parser aliases. + PACKAGE_VERSION_MODE: ${{ inputs.version_mode == 'follow-dsh' && 'product' || inputs.version_mode == 'build-number' && 'auto' || inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} @@ -184,6 +185,7 @@ jobs: timeout-minutes: 30 env: DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_ARCHIVE_NAME: macos-arm64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} steps: @@ -206,17 +208,17 @@ jobs: echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile working-directory: artifact-archiver - - name: Verify original retained build before GitHub archival + - name: Archive verified retained build + id: archive working-directory: artifact-archiver run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain installers for the selected mode if: success() uses: actions/upload-artifact@v7.0.1 with: - name: macos-arm64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} - include-hidden-files: true - path: ${{ needs.package.outputs.directory }}/* - compression-level: 0 + name: ${{ env.DESKTOP_ARCHIVE_NAME }} + path: ${{ steps.archive.outputs.path }} + archive: false if-no-files-found: error retention-days: 14 overwrite: true diff --git a/.github/workflows/pr-check-on-head-branch.yml b/.github/workflows/pr-check-on-head-branch.yml index a926d97827..ef456f5a9b 100644 --- a/.github/workflows/pr-check-on-head-branch.yml +++ b/.github/workflows/pr-check-on-head-branch.yml @@ -108,6 +108,7 @@ jobs: if: steps.merge.outputs.merged == 'true' env: CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }} + BASE_SHA: ${{ needs.master.outputs.base_sha }} run: | # The check names one file and knows nothing else about how the merged tree builds itself. if [ ! -f scripts/pre-merge-check.ts ]; then diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 0b137fc3b2..405ee6bd29 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -15,15 +15,15 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' + description: '升级通道:人工选择 nightly 或 stable,与版本后缀无关;默认通道由此打包输入确定' type: choice options: [nightly, stable] default: nightly required: true version_mode: - description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' + description: '版本模式:default 按环境自动选择(test 取 build-number,production 取 follow-dsh);follow-dsh 跟随 dsh 包版本号;build-number 自动追加构建编号;manual 手填' type: choice - options: [default, product, auto, manual] + options: [default, follow-dsh, build-number, manual] default: default required: true build_version: @@ -77,7 +77,8 @@ jobs: shell: pwsh env: PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} - PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + # TODO: Remove this legacy source_ref mode mapping on 2027-01-04 with the parser aliases. + PACKAGE_VERSION_MODE: ${{ inputs.version_mode == 'follow-dsh' && 'product' || inputs.version_mode == 'build-number' && 'auto' || inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} @@ -220,6 +221,7 @@ jobs: env: PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_ARCHIVE_NAME: windows-x64-${{ inputs.signing }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} steps: @@ -251,28 +253,17 @@ jobs: "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - run: pnpm install --frozen-lockfile working-directory: artifact-archiver - - name: Verify original retained build before GitHub archival + - name: Archive verified retained build + id: archive working-directory: artifact-archiver run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - - name: Retain verified unsigned installer - if: success() && inputs.signing == 'unsigned' + - name: Retain installers for the selected mode + if: success() uses: actions/upload-artifact@v7.0.1 with: - name: windows-x64-unsigned-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} - compression-level: 0 - include-hidden-files: true - path: ${{ needs.package.outputs.directory }}/* - if-no-files-found: error - retention-days: 14 - overwrite: true - - name: Retain verified signed installer - if: success() && inputs.signing == 'signed' - uses: actions/upload-artifact@v7.0.1 - with: - name: windows-x64-signed-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} - compression-level: 0 - include-hidden-files: true - path: ${{ needs.package.outputs.directory }}/* + name: ${{ env.DESKTOP_ARCHIVE_NAME }} + path: ${{ steps.archive.outputs.path }} + archive: false if-no-files-found: error retention-days: 14 overwrite: true diff --git a/.gitignore b/.gitignore index 51b7cf53dd..6255c7081b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ python/**/__pycache__/ python/**/.pytest_cache/ packages/**/__pycache__/ apps/web/dist/ +apps/cli/docs/ .artifacts/ .dsh-build/ .dsh-report/ diff --git a/AGENTS.md b/AGENTS.md index 278a2be722..f189147619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **No new assertions to `unknown`** (`as unknown` or ``). Preserve or reduce the exact legacy baseline; use typed values or validation for replacements ([rule](.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md)). - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). - **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). -- **Error handling:** use typed, structured failures and localized, actionable reports. Define handling ownership, containment, and failure guarantees; stop dependent work and visibly report untrusted state or unknown outcomes. Follow [dsh-error-handling](.agents/skills/dsh-error-handling/SKILL.md). +- **Error handling:** use typed, structured failures and localized, actionable reports. Coded errors and expected tool failures use `HarnessError`. Define handling ownership, containment, and failure guarantees; stop dependent work and visibly report untrusted state or unknown outcomes. Follow [dsh-error-handling](.agents/skills/dsh-error-handling/SKILL.md). - **An empty `catch` names the error** and why; keep its `try` to one statement. - **Keep comments local.** Do not restate code, expand unrelated comments, or explain distant behavior without local need ([rationale](.agents/skills/dsh-prose-standard/SKILL.md)). - **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)). diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 1ec1be3c64..a035a44693 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -17,6 +17,9 @@ /deepseek-ai-dsh/optional-overlays: en: 3a40ddc44e55f79c zh: 8d3949132e1ef93c +/deepseek-ai-dsh/installed-payload: + en: c19f3d56f611f173 + zh: 5002a7a318414960 /deepseek-ai-dsh/development: en: 0a289c1235869927 zh: a1f473f70036ad37 diff --git a/apps/cli/README.md b/apps/cli/README.md index b0059efecd..644eba2aa5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -53,6 +53,12 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f `config/examples/` ships opt-in overlays for GitHub review webhooks, memory MCP servers, and runtime Cordis tools. They are never part of a default profile; the [user guides](../../docs/user/guide/index.md) and [developer practice guides](../../docs/user/develop/practice/index.md) own setup and safety instructions. +## Installed payload + +The published tarball carries the bundled runtime under `lib/`, the TypeScript under `src/`, the declaration and JavaScript maps beside the files they describe, and the documentation under `docs/`. Only `prepack` stages `docs/`, and `postpack` removes that copy again, so a reader of the working tree sees whatever the checkout holds. + +`docs/` holds the documentation website's pages plus every upgrade guide, with the assets those pages reference. `docs/upgrade-guide/README.md` is generated during staging: it groups the guides by the release each `v` directory upgrades from and lists each guide's title, English and Chinese descriptions, and relative paths. The Desktop application carries the same `docs/` and `src/` inside its `app.asar`. The generated `persistence-changes/` records, the `i18n/` guidelines, and the `*.i18n.yaml` consistency records are not published. Its pages keep the repository's relative links, so a link into `../packages/**` or into the omitted `persistence-changes/` records resolves only in a checkout. + ## Development Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh ` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 810126e839..318e12440f 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -53,6 +53,12 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 `config/examples/` 交付 GitHub 评审 webhook、记忆 MCP 服务器与运行时 Cordis 工具的可选覆盖层。它们绝不属于默认 profile;设置与安全说明由[用户指南](../../docs/user/guide/index.zh.md)和[开发实战指南](../../docs/user/develop/practice/index.zh.md)负责。 +## 安装包内容 + +发布 tarball 在 `lib/` 下携带打包后的运行时,在 `src/` 下携带 TypeScript 源码,在各自描述的文件旁携带声明映射与 JavaScript map,并在 `docs/` 下携带文档。只有 `prepack` 会暂存 `docs/`,`postpack` 又会删除这份副本,因此工作树里的读者看到的始终是检出当前的内容。 + +`docs/` 包含文档网站的页面与全部升级指南,以及这些页面引用的资产。`docs/upgrade-guide/README.md` 在暂存时生成:它按每个 `v` 目录升级前的版本对指南分组,列出每份指南的标题、中英文描述与相对路径。桌面应用在其 `app.asar` 内携带同样的 `docs/` 与 `src/`。生成的 `persistence-changes/` 记录、`i18n/` 规范与 `*.i18n.yaml` 一致性记录不发布。其中的页面保留仓库的相对链接,因此指向 `../packages/**` 或指向被排除的 `persistence-changes/` 记录的链接只在检出中可解析。 + ## 开发 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 5bddabe27e..4be57e7f46 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -16,8 +16,16 @@ }, "files": [ "lib/*.js", - "lib/types/*.d.ts" + "lib/*.js.map", + "lib/types/*.d.ts", + "lib/types/*.d.ts.map", + "docs", + "src" ], + "scripts": { + "prepack": "tsx ../../scripts/stage-dsh-docs.ts", + "postpack": "tsx ../../scripts/stage-dsh-docs.ts --clean" + }, "license": "MIT", "dependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/apps/cli/tests/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts index e38a33777d..026a54903c 100644 --- a/apps/cli/tests/agent-team-headless.e2e.ts +++ b/apps/cli/tests/agent-team-headless.e2e.ts @@ -102,11 +102,11 @@ describe('dsh run with Agent Teams enabled', () => { expect(eventTypes).not.toContain('team/member') const catalog = root!.filter(record => record.type === 'subagent/catalog').map(record => record.data) expect(catalog).toEqual(expect.arrayContaining([ - expect.objectContaining({ version: 0, childId: workflowChild![0]?.id }), - expect.objectContaining({ version: 0, label: 'Own deterministic implementation.' }), - expect.objectContaining({ version: 0, label: 'Own deterministic research.' }), + expect.objectContaining({ version: 3, childId: workflowChild![0]?.id, name: 'agent-1' }), + expect.objectContaining({ version: 3, label: 'Own deterministic implementation.', name: 'agent-2' }), + expect.objectContaining({ version: 3, label: 'Own deterministic research.', name: 'agent-3' }), ])) - for (const entry of catalog) expect(entry).not.toHaveProperty('name') + for (const entry of catalog) expect(entry).toHaveProperty('name') expect(eventTypes).not.toContain('subagent-name/bound') expect(eventTypes).not.toContain('team/message/queued') expect(eventTypes).not.toContain('team/message/delivered') diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index c1f42d812d..a0e0ab9c92 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -13,19 +13,13 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur const modules = join(root, 'node_modules', '@deepseek-ai') const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url)) const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record } - const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths']) + const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh']) for (const name of Object.keys(manifest.dependencies)) { const destination = join(root, 'node_modules', name) mkdirSync(dirname(destination), { recursive: true }) if (stubbed.has(name)) mkdirSync(destination) else symlinkSync(realpathSync(join(hostDirectory, 'node_modules', name)), destination, 'junction') } - for (const [name, source] of [ - ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`], - ] as const) { - writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}') - writeFileSync(join(modules, name, 'index.js'), source) - } writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl index 387c24d78f..ec08d00cbb 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl @@ -8,7 +8,7 @@ {"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":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\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`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"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`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\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`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"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`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl index 5aaed59119..6254410a45 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl @@ -8,7 +8,7 @@ {"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":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\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`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"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`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\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`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"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`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index d687760a18..77602a0788 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -140,13 +140,13 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` - const runtimeContext = (cwd: string, sessionId: string): string => '\nThe following information describes your current session:\n\n' + const runtimeContext = (cwd: string): string => '\nThe following information describes your current session:\n\n' + `Current working directory: ${JSON.stringify(cwd)}.\n\n` + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\n' - + `You are the root agent. Your agent id is ${JSON.stringify(sessionId)}.\n\n` - + 'To message other agents, use send_message({ agent_id: "", message: "..." }).\n' - + 'Use list_agents({}) to find your collaborator agents and their ids.\n' + + 'You are the root agent. Your name is "lead".\n\n' + + 'To message other agents, use send_message({ agent: "", message: "..." }).\n' + + 'Use list_agents({}) to find your collaborator agents and their names.\n' const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toEqual([ @@ -156,7 +156,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: imageHandle }, fileImage, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(result.cwd, result.sessionId) }, + { type: 'text', text: runtimeContext(result.cwd) }, ]) const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> @@ -208,7 +208,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(fallback.cwd, fallback.sessionId) }, + { type: 'text', text: runtimeContext(fallback.cwd) }, ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl index 2ca0fc0267..f52d624277 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index a442227544..e5285d9d47 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -8,7 +8,7 @@ {"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":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\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`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"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`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\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`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"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`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"cli-mock","model":"cli-mock"}} {"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl index 437e7d7e2d..9f15c8c621 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl index ac43a04e5b..cd0ac8dcb4 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl index 61f3967cea..a8b01c5d5d 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index 31cb13438b..2169895dcb 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -18,7 +18,7 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index 6920ab1442..499bdc083c 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -16,13 +16,13 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [running] — root agent\n{{sessionId}} [inactive] parent={{sessionId}} depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"lead [running] — root agent\n{{sessionId}} [inactive] — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index 760da506d4..23df1c78fb 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -8,7 +8,7 @@ {"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":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\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`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"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`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\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`).\n\nYou are a subagent delegated by agent \"lead\".\nYour name is \"agent-1\".\nThe root agent of your collaboration tree is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"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`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"lead\".\nYour name is \"agent-1\".\nThe root agent of your collaboration tree is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index bb5dc6c653..311240e645 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -16,20 +16,20 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} {"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Delegated write probe"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"subagent/catalog","data":{"version":3,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Delegated write probe","name":"agent-1"}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent agent-1 finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent agent-1 finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent agent-1"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","name":"agent-1"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent agent-1 finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent agent-1 finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index c29a5040d8..7fd42a7bc1 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -8,7 +8,7 @@ {"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":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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`).\n\nYou are a subagent delegated by agent \"lead\".\nYour name is \"agent-1\".\nThe root agent of your collaboration tree is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"lead\".\nYour name is \"agent-1\".\nThe root agent of your collaboration tree is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index 146ea5c2ef..b5258bae32 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable subagent and","messageSeqs":[8],"source":{"kind":"fallback"}}}} @@ -12,16 +12,16 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":3,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result","name":"agent-1"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent agent-1"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","name":"agent-1"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent agent-1 finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent agent-1 finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent agent-1 finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent agent-1 finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_SETTLEMENT_NOTICE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index c547521b62..ef03e426f1 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index a6c1de1d81..5ac9beb785 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 62751cd93f..a6b334193a 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -87,7 +87,7 @@ function researcher(messages) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-1', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { agent_id: sibling, message: 'Research complete: use the deterministic finding.' } }, + { name: 'send_message', args: { agent: sibling, message: 'Research complete: use the deterministic finding.' } }, ]) } return textChunks('Research teammate complete.') @@ -134,7 +134,7 @@ function implementer(messages, parentId) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { agent_id: parentId, message: 'Implementation complete and verified.' } }, + { name: 'send_message', args: { agent: parentId, message: 'Implementation complete and verified.' } }, ]) } return textChunks('Implementation teammate complete.') @@ -203,7 +203,7 @@ class TeamFixtureAdapter extends LlmAdapter { if (workflowChild) chunks = textChunks('Fresh workflow child complete.') else if (task?.startsWith('RESEARCHER_MARK:')) chunks = researcher(options.messages) else if (task?.startsWith('IMPLEMENTER_MARK:')) chunks = implementer(options.messages, parentId) - else if (role?.startsWith('You are the root agent. Your agent id is')) chunks = lead(options.messages) + else if (role?.startsWith('You are the root agent. Your name is')) chunks = lead(options.messages) else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { options.signal?.throwIfAborted() diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 9f53fb980a..69c95ef485 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -1254,18 +1254,18 @@ describe('headless stream-json snapshots', () => { "checkedAgents": true, "deliveredMessages": 0, "identities": [ - "You are a subagent delegated by agent "". - Your agent id is "". - The root agent of your collaboration tree is "". + "You are a subagent delegated by agent "lead". + Your name is "agent-2". + The root agent of your collaboration tree is "lead". - To message other agents, use send_message({ agent_id: "", message: "..." }). - Use list_agents({}) to find your collaborator agents and their ids.", - "You are a subagent delegated by agent "". - Your agent id is "". - The root agent of your collaboration tree is "". + To message other agents, use send_message({ agent: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their names.", + "You are a subagent delegated by agent "lead". + Your name is "agent-3". + The root agent of your collaboration tree is "lead". - To message other agents, use send_message({ agent_id: "", message: "..." }). - Use list_agents({}) to find your collaborator agents and their ids.", + To message other agents, use send_message({ agent: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their names.", ], "queuedMessages": 0, "sessions": 4, diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index 3aff474038..c89eb1703c 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -132,8 +132,8 @@ describe('parent-owned cold child catalog snapshot', () => { if (parent === undefined) throw new Error('missing persisted parent log') // Discovery reads child catalogs without requiring activation descriptors. - expect(parent).toContain(`${childId} [inactive] parent=${parentId} depth=1 — descriptorless child`) - expect(parent).not.toContain('[diagnostic: corrupt]') + expect(parent).toContain(`${childId} [inactive] — descriptorless child`) + expect(parent).not.toContain(`${childId} [corrupt]`) const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } const normalizedParent = normalizeSessionSnapshot(parent, context) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 81c9eb56ea..ffe6efef60 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { zstdDecompressSync } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' @@ -537,31 +537,31 @@ async function officeFixture(root: string, pythonOnly: boolean) { await mkdir(paths.pythonPackages, { recursive: true }) if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true }) await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) - await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(root, 'resources', 'office-skills'), { recursive: true }) return { source, paths } } -it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { +it.each(['unset', 'empty', 'full', 'python-only', 'python-only-patched', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { const root = await mkdtemp(join(tmpdir(), 'sdk-office-')) onTestFinished(() => rm(root, { recursive: true, force: true })) const enabled = mode !== 'unset' && mode !== 'empty' const { source, paths } = await officeFixture(root, mode.startsWith('python-only')) - if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true }) if (mode === 'wrong-type') { await rm(paths.python) await mkdir(paths.python) } const requests: Record[] = [] + const toolUses = [ + ...enabled ? [{ type: 'tool_use', id: 'workspace-dependencies', name: 'load_workspace_dependencies', input: {} } as const] : [], + { type: 'tool_use', id: 'office-docx', name: 'skill', input: { name: 'office-docx' } } as const, + ] const server = createServer((request, response) => { let body = '' request.setEncoding('utf8').on('data', (chunk: string) => { body += chunk }) request.on('end', () => { requests.push(JSON.parse(body) as Record) - const query = requests.length === 1 && enabled + const toolUse = toolUses[requests.length - 1] response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end(messagesResponse(query - ? { type: 'tool_use', id: 'workspace-dependencies', name: 'load_workspace_dependencies', input: {} } - : { type: 'text', text: 'done' }, query ? 'tool_use' : 'end_turn')) + response.end(toolUse === undefined ? messagesResponse({ type: 'text', text: 'done' }, 'end_turn') : messagesResponse(toolUse, 'tool_use')) }) }) onTestFinished(() => new Promise((resolve) => { server.close(() => { resolve() }) })) @@ -570,12 +570,16 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli if (address === null || typeof address === 'string') throw new Error('model fixture did not bind') const home = join(root, 'home') const cliPatch = join(root, 'cli.patch.yml') - await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills'), cli: false } }] : [])) + const packagedSkills = join(repoRoot, 'packages/skill/skill-office/assets') + const patchedSkills = join(root, 'patched-skills') + if (mode === 'python-only-patched') await cp(packagedSkills, patchedSkills, { recursive: true }) + // This patch replaces the row's config with only assetRoot; the skill-office default keeps directing the model to + // the bundled environment. + await writeFile(cliPatch, JSON.stringify(mode === 'python-only-patched' ? [{ id: 'skill-office', config: { assetRoot: patchedSkills } }] : [])) const officeLaunch = resolveExampleLaunch({ srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', configArgs: ['--profile', 'sdk', '--patch', cliPatch], - env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' || mode === 'bundled' ? undefined : mode === 'empty' ? '' : source + '/', - DSH_BUNDLED_PRIMARY_RUNTIME: mode === 'unset' ? undefined : mode === 'bundled' || mode === 'empty' ? source : join(root, 'unused-default'), + env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' ? undefined : mode === 'empty' ? '' : source + '/', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, }) @@ -602,14 +606,25 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli }, () => stderr) const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) expect(names.includes('load_workspace_dependencies')).toBe(enabled) - expect(JSON.stringify(requests[0]!.messages).includes('`office`')).toBe(enabled && mode !== 'missing-assets') - expect(names).not.toContain('office_tools') + expect(JSON.stringify(requests[0]!.messages)).toContain('`office`') + const officeTools = ['office_convert', 'office_recalculate', 'office_render'] + expect(names.filter(name => name.startsWith('office'))).toEqual([]) + const lastNames = (requests.at(-1)!.tools as { name: string }[]).map(value => value.name) + // Loading an Office skill declares the Office tools from the following request. + expect(lastNames.filter(name => name.startsWith('office')).sort()).toEqual(officeTools) expect(names).not.toContain('render_document') for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') + expect(JSON.stringify(requests[0]!.messages)).toContain(name) } + expect(requests).toHaveLength(toolUses.length + 1) + const messages = requests.at(-1)!.messages as { content: { type: string; tool_use_id?: string; content?: unknown }[] }[] + const skill = JSON.stringify(messages.flatMap(message => message.content).find(block => block.tool_use_id === 'office-docx')) + const skillDirectory = join(mode === 'python-only-patched' ? patchedSkills : packagedSkills, 'office-docx').split(sep).join('/') + expect(skill).toContain(JSON.stringify(`Base directory for this skill: ${skillDirectory}`).slice(1, -1)) + expect(skill).toContain('## Python environment') + expect(skill.includes('Otherwise call `load_workspace_dependencies`')).toBe(enabled) + expect(skill.includes('`python3` or `python` on PATH')).toBe(!enabled) if (mode === 'wrong-type') { - expect(requests).toHaveLength(2) const messages = requests[1]!.messages as { content: { type: string; tool_use_id?: string; content?: unknown }[] }[] const result = messages.flatMap(message => message.content).find(block => block.tool_use_id === 'workspace-dependencies') expect(JSON.parse(JSON.stringify(result).replaceAll(JSON.stringify(paths.python).slice(1, -1), ''))).toMatchInlineSnapshot(` @@ -626,7 +641,6 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli } `) } else if (enabled) { - expect(requests).toHaveLength(2) const content: unknown = expect.arrayContaining([ expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies', content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }), @@ -636,7 +650,6 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli ])) } if (mode === 'python-only') expect(stderr).not.toContain('ENOENT') - if (mode === 'missing-assets') expect(stderr).toContain('SKILL.md') await send(3, 'shutdown') const exit = await child expect(exit.timedOut).toBe(false) diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index 7c8a94748d..5d494d00ce 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -1,11 +1,11 @@ /** - * Agent discovery of the installed Office CLI through the shipped SDK profile. - * Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a - * relocated Node or Electron carrier; CARRIER=sdk selects the standalone executable. - * NODE/CLI/ASSETS under the same prefix select Desktop command and skill resources outside ASAR. - * API=openai-completions selects a development gateway instead of the default Messages API. + * PDF export through the shipped SDK profile: the agent loads an Office skill, converts with `office_convert`, and + * copies the temporary PDF into the workspace with the shell, with no Node on PATH. + * Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a relocated Node or Electron carrier; + * CARRIER=sdk selects the standalone executable. API=openai-completions selects a development gateway instead of the + * default Messages API. */ -import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile, chmod } from 'node:fs/promises' +import { access, chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,27 +20,35 @@ const carrier = process.env.DSH_OFFICE_TEST_CARRIER ?? 'npm' const executable = process.env.DSH_OFFICE_TEST_EXECUTABLE ?? process.execPath const entry = process.env.DSH_OFFICE_TEST_ENTRY ?? join(repo, 'apps/cli/lib/bin.js') -it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent discovers and executes the ${carrier} Office CLI without Node on PATH`, async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh office discovery ')) +/** One model tool call reported by the SDK event stream. */ +interface ToolCall { + readonly name?: string + readonly arguments?: unknown +} + +function callArguments(call: ToolCall): Record { + if (typeof call.arguments !== 'string') throw new Error(`Tool call ${call.name} did not contain serialized arguments`) + return JSON.parse(call.arguments) as Record +} + +it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent exports a PDF with office_convert in the ${carrier} carrier without Node on PATH`, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh office export ')) try { - await writeFile(join(root, 'input.docx'), zipSync({ + const source = zipSync({ '[Content_Types].xml': strToU8(''), '_rels/.rels': strToU8(''), - 'word/document.xml': strToU8('Office CLI discovery'), - })) + 'word/document.xml': strToU8('Office PDF export'), + }) + await writeFile(join(root, 'input.docx'), source) const patch = join(root, 'office.patch.yml') - const node = process.env.DSH_OFFICE_TEST_NODE, cli = process.env.DSH_OFFICE_TEST_CLI - const assetRoot = process.env.DSH_OFFICE_TEST_ASSETS await writeFile(patch, JSON.stringify([ ...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []), { id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } }, - { id: 'skill-office', disabled: false }, - { id: 'skill-office', config: { ...assetRoot === undefined ? {} : { assetRoot }, ...node === undefined ? {} : { node }, ...cli === undefined ? {} : { cli } } }, ])) // A private POSIX tool directory also excludes /usr/bin/node on Linux. const path = join(root, 'bin') await mkdir(path) - for (const name of ['bash', 'sh', 'cat', 'ls', 'head', 'tail', 'mkdir']) { + for (const name of ['bash', 'sh', 'cat', 'cp', 'ls', 'head', 'tail', 'mkdir', 'od', 'wc']) { const binary = (existsSync('/bin/' + name) ? '/bin/' : '/usr/bin/') + name await access(binary) await symlink(binary, join(path, name)) @@ -51,15 +59,15 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n') await chmod(decoy, 0o755) } - const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v dsoffice'], { env: { PATH: path }, reject: false }) - expect(lookup.exitCode, 'the fixture must not expose Node or the Office CLI through PATH').not.toBe(0) + const lookup = await execa('/bin/sh', ['-c', 'command -v node'], { env: { PATH: path }, reject: false }) + expect(lookup.exitCode, 'the fixture must not expose Node through PATH').not.toBe(0) const child = execa(executable, [ ...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch, ], { cwd: root, env: { PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', - DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '', + DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : process.env.DSH_PRIMARY_RUNTIME, }, timeout: 110_000, killSignal: 'SIGKILL', reject: false, }) @@ -74,7 +82,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent const value = JSON.parse(line) as Record records.push(value) if (value.id === 1) send(2, 'session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text: - 'Load the appropriate Office skill and convert input.docx to result.pdf. Also save the installed LibreOffice Kit capabilities command output verbatim to capabilities.json. Use the supplied runtime; do not install anything. Report briefly after the files exist.', + 'Export input.docx to result.pdf in this directory. Use the supplied tools; do not install anything. Report briefly after the file exists.', }] }) const params = value.params as { event?: { type?: string } } | undefined if (value.error !== undefined || params?.event?.type === 'turn/end') send(3, 'shutdown') @@ -93,25 +101,20 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent expect(result.exitCode, diagnostic).toBe(0) expect(records.filter(record => record.error !== undefined), diagnostic).toEqual([]) const calls = records.flatMap((record) => { - const params = record.params as { event?: { type: string; data: { name?: string; arguments?: unknown } } } | undefined + const params = record.params as { event?: { type: string; data: ToolCall } } | undefined return params?.event?.type === 'tool/call' ? [params.event.data] : [] }) - expect(calls.some(call => call.name === 'skill'), diagnostic).toBe(true) - const commands = calls.filter(call => call.name === 'bash') - expect(commands.length, diagnostic).toBeGreaterThan(0) - expect(calls.findIndex(call => call.name === 'skill'), diagnostic).toBeLessThan(calls.findIndex(call => call.name === 'bash')) - const commandText = commands.map((call) => { - if (typeof call.arguments !== 'string') throw new Error('Shell tool call did not contain serialized arguments') - return (JSON.parse(call.arguments) as { command: string }).command - }).join('\n') - for (const operation of ['capabilities', 'convert']) expect(commandText, diagnostic).toMatch(new RegExp(`\\s${operation}\\b`, 'u')) - expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false) - const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } } - expect(capabilities.runtime.version).toBe('0.1.5') - expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u) + const skill = calls.findIndex(call => call.name === 'skill') + const convert = calls.findIndex(call => call.name === 'office_convert' && callArguments(call).format === 'pdf') + const copy = calls.findIndex(call => call.name === 'bash' && /\bcp\b/u.test(String(callArguments(call).command))) + expect(skill, diagnostic).toBeGreaterThanOrEqual(0) + expect(convert, diagnostic).toBeGreaterThan(skill) + expect(copy, diagnostic).toBeGreaterThan(convert) + expect(existsSync(decoyMarker), 'the agent must not invoke system Office').toBe(false) const pdf = await readFile(join(root, 'result.pdf')) expect(pdf.subarray(0, 5).toString()).toBe('%PDF-') expect(pdf.length).toBeGreaterThan(1000) + expect(await readFile(join(root, 'input.docx'))).toEqual(Buffer.from(source)) } finally { lines.close() child.stdin.end() diff --git a/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts index 9c47584c49..5f09f2fdc9 100644 --- a/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts @@ -1,11 +1,12 @@ -/** Ordinary Web leaves Office authoring composition to its carrier. */ +/** Ordinary Web mounts Office skills over the user's Python and leaves the bundled Python environment to Desktop. */ import { expect, it } from 'vitest' import { withDefaultWeb } from './default-web-process.ts' -it('does not mount Office skills, commands, or authoring dependencies in ordinary Web', async (test) => { +it('mounts Office skills without bundled authoring dependencies in ordinary Web', async (test) => { await withDefaultWeb(test, async ({ request }) => { const roster = await request('roster') - for (const name of ['skill-office', 'office-commands', 'tool-workspace-dependencies']) { + expect(roster.plugins.some(plugin => plugin.name === 'skill-office')).toBe(true) + for (const name of ['office-commands', 'tool-workspace-dependencies']) { expect(roster.plugins.some(plugin => plugin.name === name)).toBe(false) } }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index ff5fd57cae..e3915c4ad6 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -301,7 +301,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'office_convert', 'office_recalculate', 'office_render', 'plugin_manager', 'present', 'read', 'read_image', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'send_message', 'skill', 'subagent', 'subagent_fork', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'todo_write', 'update_goal', 'wait_agent', 'web_fetch', 'web_search', @@ -345,7 +345,7 @@ describe('the shipped Web composition', () => { 'model', 'provider', 'reasoning_effort', ])) for (const agent of [disabled.agent, enabled.agent]) { - expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'prompt']) + expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'name', 'prompt']) } } finally { await policy.dispose() @@ -662,9 +662,11 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) try { - // The host (global) view carries the deployment-level provider alone: + // The host (global) view carries the deployment-level providers alone: // local discovery moved behind the presets with `skill-filesystem`. - expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge']) + expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual([ + 'dsh-badge', 'office', 'office-docx', 'office-pptx', 'office-xlsx', + ]) // The standard agent's view merges the global layer with its preset's // own local discovery over the session cwd. @@ -802,7 +804,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac }) try { for (const product of installed) { - expect(toolParameterNames(productCtx, handle.agent, toolName(product))).toEqual(['cwd', 'description', 'prompt']) + expect(toolParameterNames(productCtx, handle.agent, toolName(product))).toEqual(['cwd', 'description', 'name', 'prompt']) } } finally { await handle.dispose() } expect(spawn).not.toHaveBeenCalled() diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 9e4d9cc0f4..c9f86adbf5 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "private": true, "license": "MIT", "type": "module", @@ -12,33 +12,25 @@ ], "dependencies": { "koffi": "3.1.1", - "@deepseek-ai/libreoffice-kit": "^0.1.5", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-host-webserver": "workspace:*", - "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", - "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*", - "@deepseek-ai/dsh-skill-office": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-loader": "workspace:~", - "@deepseek-ai/cordis-plugin-include": "workspace:~", - "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", - "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-tool-executor-native": "workspace:*" + "@deepseek-ai/dsh-brand": "workspace:*" } } diff --git a/apps/desktop-host/src/cli.ts b/apps/desktop-host/src/cli.ts index 6c890f8990..10757b7ab1 100644 --- a/apps/desktop-host/src/cli.ts +++ b/apps/desktop-host/src/cli.ts @@ -1,17 +1,24 @@ /** Public dsh commands using the immutable runtime carried by the Desktop installation. */ -import { delimiter, dirname, join, resolve } from 'node:path' +import { basename, delimiter, dirname, join, resolve } from 'node:path' import { runCli } from '@deepseek-ai/dsh/lib/bin.js' -import { installOfficeEngineResolution, runtimeArchivePath } from './office-engine.ts' + +/** + * Locate the archive containing a packaged runtime. + * @param runtimeDir - Prepared or ASAR-contained runtime directory. + * @returns Parent archive path, or undefined for a prepared directory. + */ +export function runtimeArchivePath(runtimeDir: string): string | undefined { + const parent = dirname(runtimeDir) + return basename(parent) === 'app.asar' ? parent : undefined +} /** * Run the ordinary CLI with Desktop's bundled package manager and reserved-profile plugin access. - * @param runtimeDir - Prepared or ASAR-contained production DSH package tree. * @param supportDir - Physical Desktop runtime directory containing the primary-runtime payload and Node launchers. * @returns Completion of the selected CLI command; profile plugins own their process lifetime. */ -export async function runDesktopCli(runtimeDir: string, supportDir: string): Promise { - installOfficeEngineResolution(runtimeDir) +export async function runDesktopCli(supportDir: string): Promise { await runCli({ manageDesktopProfile: true, packageManager: { @@ -32,5 +39,5 @@ if (import.meta.main) { await installWindowsCliSignals() } const runtimeDir = resolve(import.meta.dirname, '../../../..') - await runDesktopCli(runtimeDir, join(dirname(runtimeArchivePath(runtimeDir) ?? runtimeDir), 'runtime')) + await runDesktopCli(join(dirname(runtimeArchivePath(runtimeDir) ?? runtimeDir), 'runtime')) } diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 31bbbffe15..966e51a364 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -9,14 +9,11 @@ import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-deepseek-account' -import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import * as desktopOffice from './office.ts' import { localDragResource } from './file-drag.ts' import { installDesktopUpdateTaskControl } from './update-tasks.ts' import { installDesktopQuitInspection } from './quit-inspection.ts' import { installPlatformSessionPublisher } from './platform-session.ts' -import { installOfficeEngineResolution } from './office-engine.ts' import { installOperationDiagnostics } from './operation-diagnostics.ts' async function main(): Promise { @@ -24,7 +21,8 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'starting', runId }, (error) => { if (error !== null) console.error(error) }) const runtimeDir = process.argv[2] as string const projectDir = process.argv[3] as string - installOfficeEngineResolution(runtimeDir) + // Set before the launch-environment snapshot and profile evaluation; empty means the payload sits beside the runtime directory. + process.env.DSH_PRIMARY_RUNTIME = process.argv[4] || join(runtimeDir, '..', 'runtime', 'primary-runtime') const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) reportSkippedBundles('dsh', profile) @@ -131,11 +129,6 @@ async function main(): Promise { installOperationDiagnostics(ctx, record => send({ type: 'plugin-operation', record }), 32, runId) control.updateTasks = installDesktopUpdateTaskControl(ctx) control.quitInspection = installDesktopQuitInspection(ctx) - await ctx.plugin(desktopOffice, { - runtimeDir, - source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'), - root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'), - }) installPlatformSessionPublisher(ctx, (session) => { if (process.connected) process.send?.({ type: 'platform-session', session }) }) diff --git a/apps/desktop-host/src/office-engine.ts b/apps/desktop-host/src/office-engine.ts deleted file mode 100644 index f58019d116..0000000000 --- a/apps/desktop-host/src/office-engine.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Resolve packaged Office engine manifests from their complete, unpacked resource directories. */ -import { registerHooks, type ModuleHooks } from 'node:module' -import { realpathSync } from 'node:fs' -import { basename, dirname, join, relative } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - -/** - * Locate the archive containing a packaged runtime. - * @param runtimeDir - Prepared or ASAR-contained runtime directory. - * @returns Parent archive path, or undefined for a prepared directory. - */ -export function runtimeArchivePath(runtimeDir: string): string | undefined { - const parent = dirname(runtimeDir) - return basename(parent) === 'app.asar' ? parent : undefined -} - -/** - * Keep engine executable and resource paths usable by native child processes outside Electron. - * Hooks apply only to this thread; worker threads must install their own resolver. - * @param runtimeDir - Prepared or ASAR-contained dsh runtime directory. - * @returns Installed resolver for the Host lifetime, or undefined for a non-ASAR runtime. - */ -export function installOfficeEngineResolution(runtimeDir: string): ModuleHooks | undefined { - if (runtimeArchivePath(runtimeDir) === undefined) return undefined - const root = realpathSync(runtimeDir) - const archive = dirname(root) - const source = pathToFileURL(join(root, 'node_modules', '@deepseek-ai', 'libreoffice-kit-')).href - const destination = pathToFileURL(join(`${archive}.unpacked`, relative(archive, root), 'node_modules', '@deepseek-ai', 'libreoffice-kit-')).href - return registerHooks({ - resolve(specifier, context, nextResolve) { - const resolved = nextResolve(specifier, context) - if (!resolved.url.startsWith('file:')) return resolved - const engineRequest = /^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u.test(specifier) - const engineTarget = /\/node_modules\/@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-[^/]+\//u - .test(new URL(resolved.url).pathname) - if (!engineRequest && !engineTarget) return resolved - const canonical = pathToFileURL(realpathSync(fileURLToPath(resolved.url))).href - if (!canonical.startsWith(source)) { - if (canonical.startsWith(pathToFileURL(archive + '/').href)) { - throw new Error(`desktop Office engine resolved outside the runtime package directory: ${resolved.url}`) - } - return resolved - } - const physical = realpathSync(fileURLToPath(destination + canonical.slice(source.length))) - return { ...resolved, url: pathToFileURL(physical).href } - }, - }) -} diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts deleted file mode 100644 index 7ed14dd3b3..0000000000 --- a/apps/desktop-host/src/office.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Desktop Office skills and bundled authoring dependencies. */ - -import { realpathSync } from 'node:fs' -import { dirname, join, relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { runtimeArchivePath } from './office-engine.ts' -import type { Context } from '@deepseek-ai/cordis' -import * as officeSkills from '@deepseek-ai/dsh-skill-office' -import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' - -/** Loader identity for the application-owned Office composition. */ -export const name = 'desktop-office' -/** Application-selected bundled payload and installation directories. */ -export interface Config { - /** Bundled payload directory. Missing sibling `office-skills` resources fail Host startup. */ - readonly source: string - /** Harness-home directory where workspace dependencies are installed. */ - readonly root: string - /** Prepared or ASAR-contained application dependency directory. */ - readonly runtimeDir: string -} - -/** - * Enable offline Office authoring and LibreOffice Kit operations in the Desktop profile. - * @param ctx - Profile scope; child plugins declare their own service requirements. - * @param config - Bundled payload source and Harness-home installation root. - */ -export async function apply(ctx: Context, config: Config): Promise { - await ctx.plugin(workspaceDependencies, config) - const archive = runtimeArchivePath(config.runtimeDir) === undefined ? undefined : dirname(realpathSync(config.runtimeDir)) - const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json')) - const packageRoot = dirname(archive === undefined ? manifest : join(`${archive}.unpacked`, relative(archive, manifest))) - await ctx.plugin(officeSkills, { - assetRoot: join(dirname(config.source), 'office-skills'), - node: join(config.source, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node'), - cli: join(packageRoot, 'lib', 'cli.js'), - }) -} diff --git a/apps/desktop-host/tests/office-engine.spec.ts b/apps/desktop-host/tests/office-engine.spec.ts deleted file mode 100644 index 83c544dc01..0000000000 --- a/apps/desktop-host/tests/office-engine.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { createRequire, type ModuleHooks } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, sep } from 'node:path' -import { afterEach, expect, it } from 'vitest' -import { installOfficeEngineResolution } from '../src/office-engine.ts' -import { PackageManifests } from '../../../packages/boot/hmr/src/package-manifest.ts' - -const roots: string[] = [] -const hooks: ModuleHooks[] = [] -const disposers: Array<() => void> = [] -afterEach(() => { - for (const dispose of disposers.splice(0).reverse()) dispose() - for (const hook of hooks.splice(0)) hook.deregister() - const cache = createRequire(import.meta.url).cache - for (const path of Object.keys(cache)) { - if (roots.some(root => path.startsWith(root + sep))) Reflect.deleteProperty(cache, path) - } - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -function fixture(runtimeName = 'dsh') { - const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-office-resolution-'))) - roots.push(root) - const runtime = join(root, 'app.asar', runtimeName) - const manifest = 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/package.json' - for (const base of [runtime, join(root, 'app.asar.unpacked', runtimeName)]) { - const path = join(base, manifest) - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify({ name: '@deepseek-ai/libreoffice-kit-darwin-arm64', path: realpathSync(dirname(path)) })) - } - const api = join(runtime, 'node_modules/@deepseek-ai/libreoffice-kit/package.json') - mkdirSync(dirname(api), { recursive: true }) - writeFileSync(api, '{"name":"@deepseek-ai/libreoffice-kit"}') - const require: (specifier: string) => unknown = createRequire(join(runtime, 'package.json')) - const hook = installOfficeEngineResolution(runtime)! - hooks.push(hook) - return { root, runtime, manifest, require } -} - -it('resolves engine manifests to physical directories and leaves unrelated modules alone', () => { - const f = fixture() - // Node 24.13 require.resolve bypasses hooks; Electron's require.resolve is covered by packaged Office smoke. - expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) - .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) - expect((f.require('node:fs') as typeof import('node:fs')).realpathSync).toBe(realpathSync) - expect(f.require('@deepseek-ai/libreoffice-kit/package.json')).toEqual({ name: '@deepseek-ai/libreoffice-kit' }) -}) - -it('resolves an absolute engine manifest to its unpacked copy', () => { - const f = fixture() - expect(f.require(join(f.runtime, f.manifest))) - .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) -}) - -it.each(['exports', 'main'] as const)('refreshes a cached plugin %s while Office resolution stays active', (field) => { - const f = fixture() - const packageRoot = join(f.root, 'plugin') - mkdirSync(packageRoot) - const manifest = join(packageRoot, 'package.json') - const a = join(packageRoot, 'a.cjs') - const b = join(packageRoot, 'b.cjs') - writeFileSync(manifest, JSON.stringify({ name: 'plugin', [field]: './a.cjs' })) - writeFileSync(a, 'module.exports = { marker: "a" }') - writeFileSync(b, 'module.exports = { marker: "b" }') - symlinkSync(packageRoot, join(f.runtime, 'node_modules', 'plugin'), process.platform === 'win32' ? 'junction' : 'dir') - const require = createRequire(join(f.runtime, 'package.json')) - const original: unknown = require('plugin') - const next: unknown = require(b) - const originalModule = require.cache[a] - const nextModule = require.cache[b] - const engine = f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') - expect(original).toEqual({ marker: 'a' }) - expect(next).toEqual({ marker: 'b' }) - expect(require('plugin')).toBe(original) - const manifests = new PackageManifests() - disposers.push(() => { manifests.dispose() }) - writeFileSync(manifest, JSON.stringify({ name: 'plugin', [field]: './b.cjs' })) - manifests.invalidate(manifest) - expect(require('plugin')).toBe(next) - expect(require.resolve('plugin')).toBe(b) - expect(require.cache[a]).toBe(originalModule) - expect(require.cache[b]).toBe(nextModule) - expect(require(a)).toBe(original) - expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')).toBe(engine) - expect(f.require(join(f.runtime, f.manifest))).toBe(engine) -}) - -it('rejects an engine missing from the unpacked tree instead of using its archived copy', () => { - const f = fixture() - rmSync(join(f.root, 'app.asar.unpacked'), { recursive: true }) - expect(() => { f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') }).toThrow() -}) - -it('leaves a prepared runtime without an archive unchanged', () => { - const root = mkdtempSync(join(tmpdir(), 'desktop-office-prepared-')) - roots.push(root) - expect(installOfficeEngineResolution(join(root, 'dsh'))).toBeUndefined() -}) - -it('preserves a renamed runtime directory when locating the unpacked engine', () => { - const f = fixture('alternate-runtime') - expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) - .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'alternate-runtime', f.manifest))) }) -}) - -it('resolves an engine through a directory alias', () => { - const f = fixture() - const alias = join(f.root, 'alias') - symlinkSync(join(f.root, 'app.asar'), alias, 'junction') - const require: (specifier: string) => unknown = createRequire(join(alias, 'dsh', 'package.json')) - expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) - .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) -}) - -it('rejects an engine resolved elsewhere inside the archive', () => { - const f = fixture() - const other = join(f.root, 'app.asar', 'other', f.manifest) - mkdirSync(dirname(other), { recursive: true }) - writeFileSync(other, '{}') - const require = createRequire(join(f.root, 'app.asar', 'other', 'package.json')) - expect(() => { require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') }) - .toThrow('outside the runtime package directory') -}) - -it('leaves external engines and the archived WASM engine at their own locations', () => { - const f = fixture() - const external = join(f.root, 'external', f.manifest) - const wasm = join(f.runtime, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/package.json') - for (const path of [external, wasm]) { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify({ path: realpathSync(dirname(path)) })) - } - const require: (specifier: string) => unknown = createRequire(join(f.root, 'external', 'package.json')) - expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) - .toMatchObject({ path: realpathSync(dirname(external)) }) - expect(f.require('@deepseek-ai/libreoffice-kit-wasm/package.json')) - .toMatchObject({ path: realpathSync(dirname(wasm)) }) -}) diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts deleted file mode 100644 index 33adbf68e7..0000000000 --- a/apps/desktop-host/tests/office.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' -import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, sep } from 'node:path' -import { pathToFileURL } from 'node:url' -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 SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import SkillRegistry from '@deepseek-ai/dsh-skill' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import { expect, it, vi } from 'vitest' -import * as desktopOffice from '../src/office.ts' - -it('loads Desktop Office CLI paths and removes them on disposal', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-office-')) - vi.stubEnv('DSH_HOME', root) - const ctx = new Context() - - try { - const assets = join(root, 'runtime', 'office-skills') - await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true }) - const nodeDirectory = join(root, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin') - await mkdir(nodeDirectory, { recursive: true }) - await cp(process.execPath, join(nodeDirectory, process.platform === 'win32' ? 'node.exe' : 'node')) - ctx.baseUrl = pathToFileURL(root).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - expect('default' in desktopOffice).toBe(false) - expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice) - const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor], - ['skills', SkillRegistry], ['office', desktopOffice], - ]) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable - const config = join(root, 'cordis.yml') - await writeFile(config, [ - '- name: agents', '- name: systemPrompt', '- name: tools', '- name: skills', '- name: office', - ' config:', ` source: ${JSON.stringify(join(root, 'runtime', 'primary-runtime'))}`, - ` root: ${JSON.stringify(join(root, 'installed'))}`, - ` runtimeDir: ${JSON.stringify(join(root, 'dsh'))}`, '', - ].join('\n')) - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) - await ctx.loader.await() - for (const entry of ctx.loader.entries()) await entry.fiber?.await() - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']) - expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx').split(sep).join('/') }) - expect((await ctx.skills.get('office-pptx'))?.content).toContain('office-render') - expect((await ctx.skills.get('office-pptx'))?.content).not.toContain(nodeDirectory) - expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies']) - const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office') - expect(entry).toBeDefined() - await entry?.fiber?.dispose() - expect(await ctx.skills.list()).toEqual([]) - expect(ctx.tools.schemas()).toEqual([]) - } finally { - try { await ctx.fiber.dispose() } - finally { - vi.unstubAllEnvs() - await rm(root, { recursive: true, force: true }) - } - } -}) diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 7b88b7a2e8..c30951a3a4 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -13,9 +13,6 @@ { "path": "../../packages/jobs/jobs" }, { "path": "../../packages/schedule/schedule" }, { "path": "../../packages/workspace/workspace" }, - { "path": "../../packages/skill/skill-office" }, - { "path": "../../packages/util/home-paths" }, - { "path": "../../packages/skill/tool-workspace-dependencies" }, { "path": "../cli" }, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 081d44f9f8..f6c7091dd7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -27,14 +27,14 @@ en: 3724c53da4f7647b zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: 3368ff9b955ada69 - zh: e9209ef6ad4fd305 + en: 9eb34500d71cbc0a + zh: 90e6a98d4c2c0f14 /deepseek-harness-desktop/bundled-command-runtime: en: 372297005b57a08b zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: - en: 160b500cdcd6a39a - zh: 6c267f1762ec3689 + en: 575d82acb5d61018 + zh: b1064786f63863f7 /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: 4951cd3ee659eb33 zh: 96bdd6358dad587a @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: 01461be5b81e8609 - zh: 58e818df821a3aa2 + en: dbe57394894b7368 + zh: a3bb788c2ce631a4 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 335991f722..1936c202cc 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -103,7 +103,7 @@ Signed Windows packaging scans the primary runtime and application production de Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. -Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. Skills live in `runtime/office-skills` outside ASAR. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations. +Desktop reads the [Office skills](../../packages/skill/skill-office/README.md) from the skill package's assets inside ASAR, and their Python authoring uses the bundled libraries above. Missing or invalid Office resources, or an unloadable Office profile row, do not stop Host startup. The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. @@ -161,7 +161,7 @@ Windows and macOS relay native fullscreen transitions and the state after each p Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy. -On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Linux keeps the application and Edit menus. +On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Its Edit menu copies Electron's, binds Paste and Match Style to ⇧⌘V, and keeps Electron's ⌥⇧⌘V as a hidden alias; the Substitutions and Speech titles come from the desktop locale, while the other items keep Electron's English labels. Linux keeps the application and Edit menus. ### Runtime and plugin activation @@ -304,7 +304,7 @@ On first launch, including after an upgrade, Desktop creates this document if it Startup loading never overwrites existing documents; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). -In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. +In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. Publishers choose versions for each channel independently of version suffixes; Stable can also deliver alpha or RC versions. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends update operations; without a mandatory policy block, the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file, then choose **Reload settings** in the error details or restart; this explicit reload applies only update settings and never rewrites the file. The reload button shows a busy indicator for at least half a second and rejects repeated clicks. Invalid files keep updates paused. Selecting a valid channel in Settings can also recover an invalid channel; malformed JSON must be repaired first. A known mandatory-update block remains enforced; its configuration-error action opens the same file-location and reload dialog. Successful channel changes trigger a fresh mandatory-policy check, waiting for an older channel’s in-flight check if necessary. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 82ccf994a4..4dca8ec938 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -103,7 +103,7 @@ Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 -Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。技能位于 ASAR 外的 `runtime/office-skills`。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 +Desktop 从 ASAR 内技能包的 assets 读取 [Office 技能](../../packages/skill/skill-office/README.zh.md),其 Python 创作使用上述内置库。Office 资源缺失或无效、Office profile 行无法加载时,都不会阻止 Host 启动。 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 @@ -161,7 +161,7 @@ Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器 Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 -macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 +macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。其 Edit 菜单复制 Electron 的默认菜单,把 Paste and Match Style 绑定到 ⇧⌘V,并保留 Electron 默认的 ⌥⇧⌘V 作为隐藏别名;Substitutions 和 Speech 标题取自桌面端语言包,其余条目保留 Electron 的英文标签。Linux 保留应用菜单和 Edit 菜单。 ### 运行时与插件激活 @@ -304,7 +304,7 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 启动读取不会覆盖已有文件;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 -在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 +在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。发布者独立于版本后缀为各渠道选择版本;正式版渠道也可以提供 alpha 或 RC 版本。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停更新操作;没有强更策略阻塞时,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效;强更页面的配置异常入口可打开同一文件定位和重读对话框。渠道切换成功后立即重新检查强更策略;如旧渠道的检查仍在进行,则等待其完成后检查新渠道。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3f95b9aa8b..c3cf87bfa4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "private": true, "license": "MIT", "author": { @@ -87,7 +87,7 @@ "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", - "zod": "4.4.3", + "zod": "4.6.6", "@deepseek-ai/dsh-util-file-drag": "workspace:*", "@deepseek-ai/dsh-client-ui-file-actions": "workspace:*", "tencentcloud-sdk-nodejs-teo": "4.1.330" diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 5015126b05..2adda05e73 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,17 +18,17 @@ en: 0b35c7471c09b27a zh: cc4a382b99fc3210 /desktop-packaging/release-versions: - en: 8cc17b1e5ae3e422 - zh: 7de3e4f1e76be3e3 + en: bdee4adb7916e6cc + zh: 2880c3594ebab7ab /desktop-packaging/configuration-and-local-builds: en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: e0a852fdda949c40 - zh: b96c613022f6189e + en: b39449c2f945cab9 + zh: a742ba9efa282be8 /desktop-packaging/macos-self-hosted-packaging: - en: 239d5ab433020745 - zh: e8916ba0f4682ae1 + en: 08619f0e26782636 + zh: 7c236cbe46be4a47 /desktop-packaging/unsigned-windows-test-installer: en: f429bc0c280e2dfa zh: 4adbcc9e906bdf80 @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 44044a5c14b75533 - zh: 39d91a369c4ec2fa + en: a92b8fda03ae3f19 + zh: 5b2f0b4e1cad1745 /desktop-packaging/desktop-runtime-preparation: en: 84be38ab3136fff7 zh: bb4ed12ee0e1dfa0 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index b5875363ae..251c554d76 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -51,7 +51,7 @@ Record the current dsh version as the base. A production Desktop release uses th Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base. -Manual Desktop CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers. +Version mode `build-number` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers. Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert: @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. -The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Stable-channel packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Operators select the channel independently of version suffixes; both channels accept release and prerelease versions. A stable product can produce a `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). @@ -109,13 +109,13 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t For supervised runner startup and recovery, follow [Windows runner machine setup](../windows-signing/README.md#machine-setup). -The `Package Desktop · Windows · x64` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. +The `Package Desktop · Windows · x64` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `version_mode` defaults to `default` (`follow-dsh` for production, `build-number` for test); `build-number` uses the CI identifier in [Release versions](#release-versions). `build_version` is required for `manual` and ignored by the other modes; its value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. -Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. +Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f source_ref= -f preset=test -f signing=unsigned -f version_mode=build-number`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. All Windows packaging modes, including test and unsigned, require the repository variable `DESKTOP_PUBLISH_RUNNERS`; configure the [original-machine route](../publishing/ci/README.md#machine-setup) before dispatch. -Open the completed run in Actions and download its Artifacts from the independent `upload-artifact` job. Installer names use the original `package_run_attempt`; diagnostic names use the current `run_attempt`. If archival fails after packaging succeeds, rerun only `upload-artifact` to reuse the retained installers. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. +Open the completed run in Actions and download its Artifacts from the independent `upload-artifact` job. Installer names use the original `package_run_attempt`; diagnostic names use the current `run_attempt`. If archival fails after packaging succeeds, fix the [archival prerequisites](../publishing/ci/README.md#recovery-and-records) and rerun only `upload-artifact` to reuse the retained installers. `windows-x64-signed---.zip` and `windows-x64-unsigned---.zip` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. Installer ZIPs record each retained copy’s modification time as local time in the DOS field and absolute time in the Unix field; this is not a guarantee of the original build time. The upload steps are `Retain installers for the selected mode` and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. @@ -127,7 +127,7 @@ Installer uploads store the already-compressed release files without ZIP recompr ## macOS self-hosted packaging -The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. +The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. Artifacts are named `macos-----.zip`, with `arch` set to `arm64` or `x64`; entry timestamps record each retained copy’s modification time as local time in the DOS field and absolute time in the Unix field; this is not a guarantee of the original build time. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. Both macOS workflows require the repository variable `DESKTOP_PUBLISH_RUNNERS`, including test and unnotarized modes. Their independent `upload-artifact` job retains installer names with the original build attempt; if archival fails after packaging succeeds, rerun only that job. See [original-machine routing and recovery](../publishing/ci/README.md#recovery-and-records). @@ -252,9 +252,9 @@ Desktop packs workspace packages locally and installs external dependencies thro The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. -The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy. +The [Office conversion provider](../../../packages/document/office-kit/README.md) is a Host service mounted by web-app, so every application that loads web-app, including `dsh web` and Desktop, provides it together with the [Office tools](../../../packages/document/tool-office/README.md). The provider [resolves the Kit's engine itself](../../../packages/document/office-kit/README.md#understand-the-implementation), including the mapping of native engine manifests to unpacked physical directories. Desktop packaging only carries and unpacks the engine: it carries the target’s declared native engine, or WASM when the kit declares no matching native target, and preparation rejects a missing target engine before packaging. The complete Office dependencies, including the JavaScript libraries and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop shell passes the primary runtime location to the Host in [main.ts](../src/main.ts), and the Host sets it as `DSH_PRIMARY_RUNTIME`; neither passes a Kit location. Office skills are available in every application that loads web-app, and Desktop reads them from the skill package inside `resources/app.asar`; only Desktop mounts the bundled Python environment (`workspace-dependencies`). On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy. -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python and runs each through the real Office provider: PDF conversion, PNG rendering, conversion to the matching OpenDocument format, and, for XLSX, recalculation, checking each output's signature. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. Windows release qualification also runs [directory and replacement checks](../scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index eac441d256..898194bcf9 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -51,7 +51,7 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。 -Desktop 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。 +版本模式 `build-number` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。 把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动: @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 -安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。操作人员独立于版本后缀选择渠道;两个渠道均接受正式版本和预发布版本。稳定产品可生成 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 @@ -109,13 +109,13 @@ macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-signing/README.zh.md#machine-setup)。 -`Package Desktop · Windows · x64` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 +`Package Desktop · Windows · x64` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`version_mode` 默认为 `default`(production 使用 `follow-dsh`,test 使用 `build-number`);`build-number` 使用[发布版本](#release-versions)中的 CI 标识。`manual` 模式必须填写 `build_version`,其他模式忽略该字段;其值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 -在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 +在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f source_ref= -f preset=test -f signing=unsigned -f version_mode=build-number` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 所有 Windows 打包模式(含 test 和 unsigned)都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`;触发前配置[原机器路由](../publishing/ci/README.zh.md#machine-setup)。 -在 Actions 打开完成的运行,下载独立 `upload-artifact` job 生成的 Artifacts。安装包名称使用原构建的 `package_run_attempt`,诊断名称使用当前 `run_attempt`。打包成功但归档失败时,仅重跑 `upload-artifact` 以复用保留的安装包。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 +在 Actions 打开完成的运行,下载独立 `upload-artifact` job 生成的 Artifacts。安装包名称使用原构建的 `package_run_attempt`,诊断名称使用当前 `run_attempt`。打包成功但归档失败时,修复[归档前置条件](../publishing/ci/README.zh.md#recovery-and-records)后仅重跑 `upload-artifact`,复用保留的安装包。`windows-x64-signed---.zip` 与 `windows-x64-unsigned---.zip` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。安装包 ZIP 用 DOS 字段的本地时间和 Unix 字段的绝对时间记录各留存副本的修改时间,不保证是原始构建时间。上传步骤名为 `Retain installers for the selected mode` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 @@ -127,7 +127,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 +[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。产物名为 `macos-----.zip`,其中 `arch` 为 `arm64` 或 `x64`;条目时间以 DOS 字段的本地时间和 Unix 字段的绝对时间记录留存副本的修改时间,不保证是原始构建时间。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 两个 macOS 工作流都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`,包括 test 和未公证模式。独立 `upload-artifact` job 的安装包名称保留原构建 attempt;打包成功但归档失败时,仅重跑该 job。参见[原机器路由与恢复](../publishing/ci/README.zh.md#recovery-and-records)。 @@ -252,9 +252,9 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安 [Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 -[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。`@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。 +[Office 转换提供方](../../../packages/document/office-kit/README.zh.md)是 web-app 挂载的 Host 服务,因此所有加载 web-app 的应用(包括 `dsh web` 和 Desktop)都提供它以及 [Office 工具](../../../packages/document/tool-office/README.zh.md)。该提供方[自行解析 Kit 的引擎](../../../packages/document/office-kit/README.zh.md#understand-the-implementation),包括把原生引擎清单映射到解包后的实体目录。Desktop 打包只负责携带和解包引擎:携带目标声明的原生引擎;kit 未声明匹配的原生目标时携带 WASM;目标引擎缺失时,准备阶段在打包前拒绝。完整的 Office 依赖(包括 JavaScript 库以及所选引擎的可执行文件、数据、许可证和声明)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop 壳在 [main.ts](../src/main.ts) 中把主运行时位置传给 Host,由 Host 设为 `DSH_PRIMARY_RUNTIME`;两者都不传 Kit 位置。所有加载 web-app 的应用都提供 Office 技能,Desktop 从 `resources/app.asar` 内的技能包读取它们;只有 Desktop 挂载内置 Python 环境(`workspace-dependencies`)。在 macOS 上,原生 helper 获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT 权限。`@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。 -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,并通过真实 Office 提供方逐一执行:PDF 转换、PNG 渲染、转换为对应的 OpenDocument 格式,以及针对 XLSX 的重算,检查每个输出的签名。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](../scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 6764ae324f..d240ef646f 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -12,23 +12,26 @@ en: 470bdf1487a739f9 zh: cd80330eaa8f730c /desktop-ci-publication/table-of-contents: - en: 34283bf8397f6836 - zh: 1798bd921c3adf6c + en: 37d76f8473b967b7 + zh: d211cc54af96672d /desktop-ci-publication/setup-sequence: en: 2be541ef35a092a4 zh: 77d87c00deb45758 /desktop-ci-publication/runner-labels-and-queue-diagnosis: - en: 0478d8e35f9a3d55 - zh: 0aa3fd828bc02691 + en: d2195ee7581b74ec + zh: 0b9847afe9a2a373 /desktop-ci-publication/machine-setup: en: 0f43f2e193e95043 zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: 3f58698708671202 - zh: be0ba215a67168d6 + en: 6325c71df74a9915 + zh: edaa8a4502ed80a1 +/desktop-ci-publication/rollout-and-website-publication: + en: ee7a9ab8c66df979 + zh: c355668bc9bdb635 /desktop-ci-publication/recovery-and-records: - en: 266afba2adeeadef - zh: 063114103585dae8 + en: d6eb9cbac5b819a0 + zh: 0de1ec24055d1007 /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 2232ea39c7..7841dcb406 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -16,6 +16,7 @@ Publish a completed package from its original machine without downloading its in - [Runner labels and queue diagnosis](#runner-labels) - [Machine setup](#machine-setup) - [Package and publish](#package-and-publish) +- [Rollout and website publication](#rollout-and-website-publication) - [Recovery and records](#recovery-and-records) - [Restore a channel](#restore-a-channel) @@ -42,7 +43,7 @@ GitHub matches every label in a job's `runs-on` list. The [default-label rules]( | --- | --- | | Windows packaging | `dsh-win-package-trial` | | macOS packaging | `self-hosted`, `macOS`, `ARM64`, `dsh-mac-package-trial` | -| Publication `prepare` and `publish` | `self-hosted` and the selected `DESKTOP_PUBLISH_RUNNERS` entry's `label` | +| All original-machine publication and rollout jobs | `self-hosted` and the selected `DESKTOP_PUBLISH_RUNNERS` entry's `label` | A runner with only `dsh-win-package-trial` can package Windows but cannot accept publication. Its routing entry does not add labels to its GitHub registration. Have a runner administrator correct the registered labels; changing the route's `target` or restarting a healthy listener does not supply a missing label. @@ -68,18 +69,32 @@ Environment approval gates this workflow, not arbitrary code running under the s ## Package and publish +The publication form accepts an optional `staging_percentage` integer from 0 to 100. It applies to the retained package’s channel (nightly or stable), appears in the approval summary, and is included in the published YAML and audit record without modifying retained artifacts. Leave it blank for the packaged default (normally full rollout). Completed publications cannot change this percentage through a retry; same-version feed conflicts remain errors. + For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. An archive-only rerun retains the original build attempt in artifact names, so this early check can miss it; local manifest qualification still rejects debugging builds before publication. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. -Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. Sources without the CI adapters fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects follow-dsh for production and build-number for test; follow-dsh preserves the selected checkout’s dsh version, build-number appends date/run/attempt in either environment, and manual requires `build_version`. Existing dispatch commands using `-f version_mode=auto` must use `-f version_mode=build-number`, and `-f version_mode=product` becomes `-f version_mode=follow-dsh`; the local CLI option `--build-version auto` is unchanged. Workflows temporarily pass legacy mode names to sources that contain the CI adapters but predate the mode rename; current parsers accept both names. Removal is planned for 2027-01-04, not automatic: remove workflow mappings and parser aliases together, then require selected sources to support the new mode names. The operator-selected channel accepts any supported build version, including alpha and RC versions; it does not change version precedence or permit client downgrades. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt and its successful package job. An archival-only retry retains the original build attempt; a failed newer package job never falls back to an older success. An explicit attempt selects that build even when its separate GitHub archival failed. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. Before installing dependencies on the original runner, preparation and the approved publish job check that the selected `publishing/.json` is readable JSON. Missing configuration reports its path; restore it and retry publication with the same package. Full configuration and signature validation still run afterward. Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. + + +## Rollout and website publication + +For a 10% → 30% → 100% rollout, first publish through COS with `staging_percentage=10` and `upload_website=false`. Then dispatch **Publish Desktop · Rollout & Website** from `master` with the original package run ID and selected attempt (defaults to 1; verify it against the package run). Set `rollout_percentage` to 30, then 100 for progressive delivery; leave it empty for website-only publication. `upload_website` defaults to false; select a percentage or enable website publication. For this sequence, enable it on the final invocation to upload the production website installer and refresh its cache. Website publication is independent of the rollout percentage and does not require 100%; the installer is available to all website download users. In website-only mode, the publish job verifies the feed without changing its percentage. Each platform uses its own package run. + +Preparation checks GitHub identity, the retained manifest, the small update YAML and a completed initial publication record; it does not rehash package binaries or repeat signature/notarization checks. After Environment approval, execution reads the current COS feed, verifies that its version, payload URL, size, hash and other metadata still match the published package, and changes only `stagingPercentage`. Missing or replaced feeds reject the operation. Repeating the same percentage is a no-op; lowering the percentage is rejected. The approval shows the requested percentage; the current percentage is read from COS during execution. + +Website upload reuses the original retained installer, hashes that installer alone and uses the existing upload/purge recovery records. Cache-only retries use the existing website approval and upload journal without requiring the retained update YAML or initial feed journal. The website upload holds the feed channel lock through installer verification and upload so a newer feed cannot replace the selected version midway; competing publications on that channel fail while the lock is held. A metadata-only rollout therefore does not require installer files, but website publication requires its installer. Percentage changes retain before/after YAML in separate rollout audit records. Initial COS publication remains required; this workflow never builds packages, uploads versioned binaries or creates release tags. + ## Recovery and records +The archival job creates a store-mode ZIP with system bsdtar: `%SystemRoot%\System32\tar.exe` on Windows, or `/usr/bin/tar` on macOS. `invalid ZIP output settings` rejects a missing or malformed archive name and missing retained-directory, temporary-directory or `GITHUB_OUTPUT` settings before file verification. `desktop archival: SystemRoot is required to locate Windows bsdtar` reports a missing Windows system directory setting. `ZIP creation failed with ` identifies the failed archive tool; inspect its stderr, tool availability and free space in `RUNNER_TEMP`. + The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). All packaging modes, including test and unsigned builds, require `DESKTOP_PUBLISH_RUNNERS` before packaging; it routes archival to the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. If an archival retry stays queued without a runner, inspect the resolved runner label and upstream package outputs before treating the runner as offline. Missing outputs require operator recovery of the original retained directory and manifest; rerunning the whole workflow rebuilds the package and is not archival-only recovery. Verify output reuse in the Actions acceptance test. Production COS publication enables website installer upload and cache refresh by default (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. Both production nightly and stable packages can replace the fixed website installer. Operators select the intended version; the fixed object has no downgrade protection, so rerunning an older publication can replace a newer installer. The pre-approval summary names the fixed website URL, selected version and lack of downgrade protection when website upload is selected. The existing version/update publication retains its own checks. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 99d245bf1a..249db63816 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -16,6 +16,7 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 - [Runner 标签与排队排查](#runner-labels) - [机器配置](#machine-setup) - [打包与发布](#package-and-publish) +- [放量与官网发布](#rollout-and-website-publication) - [恢复与记录](#recovery-and-records) - [恢复频道](#restore-a-channel) @@ -42,7 +43,7 @@ GitHub 要求 runner 同时匹配 job 的 `runs-on` 列表中的全部标签。[ | --- | --- | | Windows 打包 | `dsh-win-package-trial` | | macOS 打包 | `self-hosted`、`macOS`、`ARM64`、`dsh-mac-package-trial` | -| 发布 `prepare` 和 `publish` | `self-hosted`,以及选中的 `DESKTOP_PUBLISH_RUNNERS` 条目中的 `label` | +| 所有原机发布和放量任务 | `self-hosted`,以及选中的 `DESKTOP_PUBLISH_RUNNERS` 条目中的 `label` | 只有 `dsh-win-package-trial` 的 runner 可以打包 Windows,却不能接发布任务。路由条目不会给 GitHub 中注册的 runner 添加标签。需由 runner 管理员修正注册标签;修改路由的 `target` 或重启正常监听的进程都不能补齐缺失标签。 @@ -68,18 +69,32 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 打包与发布 +发布表单支持可选的 `staging_percentage`,取值为 0 到 100 的整数。该值作用于保留包所属的 nightly 或 stable 渠道,显示在审批摘要中,并写入发布的 YAML 和审计记录,不修改保留产物。留空沿用包内默认值(通常为全量)。已完成的发布不能通过重试修改比例;同版本清单内容冲突仍会报错。 + 发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。仅重跑归档时,artifact 名称保留原构建 attempt,早期检查可能未识别该包;本地清单资格校验仍会在发布前拒绝调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 -选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 follow-dsh,对 test 选择 build-number;follow-dsh 跟随所选源码的 dsh 包版本号,build-number 在任一环境均追加日期/run/attempt,manual 要求填写 `build_version`。已有派发命令中的 `-f version_mode=auto` 须改为 `-f version_mode=build-number`,`-f version_mode=product` 改为 `-f version_mode=follow-dsh`;本地 CLI 的 `--build-version auto` 不变。工作流暂时传递旧模式名,以兼容已含 CI 适配脚本但早于模式改名的源码;当前解析器同时接受新旧名称。计划于 2027-01-04 同时删除 workflow 映射和解析器别名,并要求所选源码支持新模式名;该日期不会触发自动失效。操作人员选择的渠道接受任意受支持的构建版本,包括 alpha 和 RC 版本;渠道不改变版本优先级,也不允许客户端降级。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试及其成功的 package job;归档重跑继续使用原始构建 attempt,新的构建失败时不回退到较早成功构建。显式填写则选择对应构建,独立的 GitHub 归档失败不取消其资格。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 在原 runner 安装依赖前,准备阶段和审批后的发布 job 都会检查所选 `publishing/.json` 是否为可读取的 JSON。配置缺失时会报告路径;补齐后可使用同一个包重试发布。之后仍会执行完整配置与签名校验。上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 + + +## 放量与官网发布 + +按 10% → 30% → 100% 放量时,先通过 COS 流程以 `staging_percentage=10`、`upload_website=false` 完成首次发布,再从 `master` 发起 **Publish Desktop · Rollout & Website**,填写原打包 run ID 并选择 attempt(默认 1,请与原打包 run 核对)。逐步放量时将 `rollout_percentage` 分别设为 30、100;仅发布官网时留空。`upload_website` 默认为 false;必须填写比例或启用官网发布。在此示例中,最后一轮启用它即可上传生产官网安装器并刷新缓存。官网发布独立于放量比例,不要求达到 100%;官网安装包面向所有下载用户。仅发布官网时,publish job 只校验 feed,不修改放量比例。每个平台使用各自的打包 run。 + +准备阶段检查 GitHub 身份、保留清单、小体积更新 YAML 和已完成的首次发布记录,不重新计算安装包哈希,也不重复检查签名或公证。Environment 审批后,执行阶段读取当前 COS 更新清单,核对版本、安装包 URL、大小、哈希和其他元数据仍对应已发布包,只修改 `stagingPercentage`。更新清单缺失或已被其他包替换时拒绝执行。同一比例重复执行不写入;不允许降低比例。审批展示目标比例,当前比例在执行时从 COS 读取。 + +官网上传复用原机保留的安装器,只校验该安装器的哈希,沿用已有上传与刷新恢复记录。仅重试缓存刷新时沿用官网审批和上传记录,不再依赖原更新 YAML 或首次 feed 发布记录。官网上传在安装器校验、上传期间保持频道锁,防止中途被更新的 feed 替换;此时竞争该频道的发布会失败。因此纯放量不需要安装器文件,发布官网则需要。比例变更前后的 YAML 写入独立放量审计记录。此流程必须在首次 COS 发布之后执行,不构建安装包、不上传版本化安装包,也不创建 release tag。 + ## 恢复与记录 +归档 job 使用系统 bsdtar 创建 store 模式 ZIP:Windows 为 `%SystemRoot%\System32\tar.exe`,macOS 为 `/usr/bin/tar`。`invalid ZIP output settings` 表示归档名称缺失或格式非法,或保留目录、临时目录、`GITHUB_OUTPUT` 缺失,会在校验文件前拒绝。`desktop archival: SystemRoot is required to locate Windows bsdtar` 表示缺失 Windows 系统目录设置。`ZIP creation failed with ` 指明失败的归档工具;检查其 stderr、工具是否可用,以及 `RUNNER_TEMP` 剩余空间。 + 打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。所有打包模式(包括 test 和 unsigned)都要求在打包前配置 `DESKTOP_PUBLISH_RUNNERS`,用于将归档路由到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。归档重跑若一直排队且未分配 runner,应先检查解析出的 runner 标签和上游 package 输出,不能直接认定机器离线。输出缺失时需由操作者从原留存目录和 manifest 恢复文件;重跑整个 workflow 会重新构建,不属于仅归档恢复。Actions 验收须验证输出复用。 生产 COS 发布默认启用官网安装包上传和缓存刷新(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。production nightly 和 stable 包都可以替换官网固定安装包。操作者负责选择预期版本;固定对象没有防降级保护,重跑旧发布可能覆盖新安装包。勾选官网上传时,审批前摘要展示固定官网 URL、选定版本和无防降级保护的说明。原有版本与自动更新发布保留自身校验。 diff --git a/apps/desktop/scripts/archive-desktop-ci.ts b/apps/desktop/scripts/archive-desktop-ci.ts index 849433b698..1517d20dd4 100644 --- a/apps/desktop/scripts/archive-desktop-ci.ts +++ b/apps/desktop/scripts/archive-desktop-ci.ts @@ -1,5 +1,7 @@ -/** Validate the original machine and immutable inventory before GitHub artifact archival. */ -import { resolve } from 'node:path' +/** Create the verified installer ZIP and expose its path to the Actions upload step. */ +import { execFileSync } from 'node:child_process' +import { appendFile, mkdtemp } from 'node:fs/promises' +import { join, resolve } from 'node:path' import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { desktopReleasesRoot } from './retain-desktop-ci.ts' import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts' @@ -7,9 +9,9 @@ import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-rel /** * Verify an original build without interpreting the archive job's attempt as a new build. * @param env Original build outputs and current Actions machine identity. - * @returns Resolves after the retained inventory passes all digest checks. + * @returns Verified file names to include alongside the manifest. */ -export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise { +export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise { if (!env.RUNNER_NAME || env.RUNNER_NAME !== env.DESKTOP_PACKAGE_RUNNER) throw new Error('desktop archival: wrong original machine') const root = desktopReleasesRoot(env) const runId = env.GITHUB_RUN_ID || '' @@ -20,11 +22,27 @@ export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise file.name)] } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { - try { await verifyDesktopArchive(process.env) } - catch (error) { + try { + const { DESKTOP_ARCHIVE_NAME: name, DESKTOP_RETAINED_DIRECTORY: directory, RUNNER_TEMP: temp, GITHUB_OUTPUT: output } = process.env + if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9.+_-]*\.zip$/u.test(name) || !directory || !temp || !output) throw new Error('desktop archival: invalid ZIP output settings') + const files = await verifyDesktopArchive(process.env) + // archive: false uses the file basename; keep it equal to the upload step's name used for overwrite deletion. + const path = join(await mkdtemp(join(temp, 'desktop-archive-')), name) + const windows = process.platform === 'win32' + if (windows && !process.env.SystemRoot) throw new Error('desktop archival: SystemRoot is required to locate Windows bsdtar') + const tar = windows ? join(process.env.SystemRoot!, 'System32', 'tar.exe') : '/usr/bin/tar' + const metadata = process.platform === 'darwin' ? ['--no-mac-metadata', '--no-xattrs'] : [] + try { + execFileSync(tar, ['--format', 'zip', '--options', 'zip:compression=store', ...metadata, '-cf', path, '--', ...files], { cwd: directory, stdio: 'inherit' }) + } catch (cause) { + throw new Error(`desktop archival: ZIP creation failed with ${tar}. Check system bsdtar availability, its stderr and free space in RUNNER_TEMP.`, { cause }) + } + await appendFile(output, `path=${path}\n`) + } catch (error) { process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'artifact archival'))}\n`) process.exitCode = 1 } diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs index bddf4ca492..5a75253f5c 100644 --- a/apps/desktop/scripts/desktop-build-version.mjs +++ b/apps/desktop/scripts/desktop-build-version.mjs @@ -2,7 +2,7 @@ * Resolve the version one build publishes, which is not always the version the * repository declares. * - * Manual Desktop CI uses UTC date, workflow run number and attempt as + * Desktop CI version mode `build-number` uses UTC date, workflow run number and attempt as * `.YYYYMMDD.run.attempt`; these identify unpublished builds, not release sequences. * * A production release publishes the version in the manifests, aligned with the diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs index 3822b8329f..da3e71d43a 100644 --- a/apps/desktop/scripts/desktop-ci-version.mjs +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -9,14 +9,16 @@ import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './deskto * @returns {string} Validated build version. */ export function desktopCiVersion(environment, productVersion, now = new Date()) { - const mode = environment.PACKAGE_VERSION_MODE - const selected = mode === 'default' ? (environment.DSH_PACKAGING_PRESET === 'production' ? 'product' : 'auto') : mode - if (selected !== undefined && !['product', 'auto', 'manual'].includes(selected)) throw new Error('Invalid version mode') - const requested = selected === 'product' ? productVersion : selected === 'auto' ? 'auto' : environment.PACKAGE_BUILD_VERSION?.trim() + const input = environment.PACKAGE_VERSION_MODE + // TODO: Remove legacy mode aliases on 2027-01-04 with the workflow source_ref mapping. + const mode = input === 'product' ? 'follow-dsh' : input === 'auto' ? 'build-number' : input + const selected = mode === 'default' ? (environment.DSH_PACKAGING_PRESET === 'production' ? 'follow-dsh' : 'build-number') : mode + if (selected !== undefined && !['follow-dsh', 'build-number', 'manual'].includes(selected)) throw new Error('Invalid version mode') + const requested = selected === 'follow-dsh' ? productVersion : selected === 'build-number' ? 'auto' : environment.PACKAGE_BUILD_VERSION?.trim() if (selected === 'manual' && (!requested || requested === 'auto')) throw new Error('Manual version mode requires an explicit version') const date = now.toISOString().slice(0, 10).replaceAll('-', '') const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1` - if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`) + if (!requested) throw new Error(`Build version is required: select version_mode=build-number, or use ${productVersion}, or e.g. ${example}`) let candidate = requested if (requested === 'auto') { for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) { @@ -24,11 +26,10 @@ export function desktopCiVersion(environment, productVersion, now = new Date()) } candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` } - if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'stable' && candidate.includes('-')) throw new Error('Stable channel requires a stable version; select product or manual stable version') try { const resolved = validateDesktopBuildVersion(candidate, productVersion) return resolved } catch { - throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`) + throw new Error(`Invalid build version for this checkout: select version_mode=build-number, or use ${productVersion}, or e.g. ${example}`) } } diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts index 3772fb0b67..2806a720f0 100644 --- a/apps/desktop/scripts/desktop-publication-failure.ts +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -46,7 +46,7 @@ export function desktopPublicationFailure(error: unknown, phase: string): { message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' } const ownedMessage = - /^(desktop (?:publish|archival|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u + /^(desktop (?:publish|rollout|archival|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u const websiteMessage = /^website (?:publication|installer|cache purge):/u if (error instanceof Error && (ownedMessage.test(error.message) || websiteMessage.test(error.message))) { return { phase, code: 'validation', message: error.message } diff --git a/apps/desktop/scripts/desktop-publication-summary.ts b/apps/desktop/scripts/desktop-publication-summary.ts index 49d438aaf2..a9e86575fd 100644 --- a/apps/desktop/scripts/desktop-publication-summary.ts +++ b/apps/desktop/scripts/desktop-publication-summary.ts @@ -1,4 +1,6 @@ /** Render the verified publication plan for GitHub's native deployment reviewers. */ +import { load } from 'js-yaml' +import { z } from 'zod' import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs' @@ -48,6 +50,10 @@ export async function desktopPublicationSummary( row('发布环境', code(plan.environment)), row('版本号', code(plan.version)), row('升级通道', code(source.channel)), + ...feeds.map((feed) => { + const metadata = z.object({ stagingPercentage: z.number().optional() }).parse(load(feed.contents!)) + return row('更新灰度比例', metadata.stagingPercentage === undefined ? '全量(未设置)' : `${metadata.stagingPercentage}%`) + }), row('平台', code(plan.target)), ...(plan.environment === 'test' ? [row('测试 release ID', `${code(source.testReleaseId!)}
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。`)] : []), row('下载地址预览', `${installers.map(file => link(url(file.key))).join('
')}
发布后的目标地址,审批前不保证可下载。`), diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts index 77ac3ca698..718146b86b 100644 --- a/apps/desktop/scripts/desktop-publish-record.ts +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -40,7 +40,7 @@ export interface DesktopPublicationRecord { */ export async function createDesktopPublicationRecord( root: string, - identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string }, + identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback' | 'rollout'; reason: string; stagingPercentage?: number | undefined }, ): Promise { await mkdir(root, { recursive: true, mode: 0o700 }) const staging = await mkdtemp(join(root, '.initializing-')) @@ -78,10 +78,31 @@ export async function wasDesktopAttemptPublished(root: string, expected: { runId: string attempt: string destination: string + stagingPercentage?: number | undefined }): Promise { + const evidence = await findDesktopPublication(root, expected) + if (!evidence) return false + if (evidence.stagingPercentage !== expected.stagingPercentage) { + throw new Error('desktop publish: staging percentage differs from the completed publication') + } + return true +} + +/** + * Find initial feed-publication evidence independently of later rollout percentages. + * @param root Original machine's publication journal directory. + * @param expected Immutable packaging identity and destination. + * @returns Original publication settings, or null when no completed feed publication exists. + */ +export async function findDesktopPublication(root: string, expected: { + repository: string + runId: string + attempt: string + destination: string +}): Promise<{ stagingPercentage?: number | undefined } | null> { let entries try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null throw error } for (const entry of entries) { @@ -93,7 +114,7 @@ export async function wasDesktopAttemptPublished(root: string, expected: { throw error } const parsed = z.object({ operation: z.string(), repository: z.string(), - runId: z.string(), attempt: z.string(), destination: z.string() }).parse(operation) + runId: z.string(), attempt: z.string(), destination: z.string(), stagingPercentage: z.number().optional() }).parse(operation) if (parsed.operation !== 'publish' || parsed.repository !== expected.repository || parsed.runId !== expected.runId || parsed.attempt !== expected.attempt || parsed.destination !== expected.destination) continue let events @@ -103,8 +124,10 @@ export async function wasDesktopAttemptPublished(root: string, expected: { } // Only newline-terminated entries were completely appended before an interruption. for (const line of events.split('\n').slice(0, -1)) { - if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') return true + if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') { + return { stagingPercentage: parsed.stagingPercentage } + } } } - return false + return null } diff --git a/apps/desktop/scripts/desktop-rollout.ts b/apps/desktop/scripts/desktop-rollout.ts new file mode 100644 index 0000000000..50750ef1e8 --- /dev/null +++ b/apps/desktop/scripts/desktop-rollout.ts @@ -0,0 +1,127 @@ +/** Change only the rollout percentage of an already published Desktop update feed. */ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { dump, load } from 'js-yaml' +import { z } from 'zod' +import { desktopUpdateMetadataFilename, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import type { DesktopRetainedManifest } from './desktop-release-store.ts' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopArtifactDigest, type DesktopPublishOperations, type DesktopRemoteObject } from './desktop-publish.ts' + +const percentage = z.number().int().min(0).max(100) +const metadataSchema = z.object({ + version: z.string(), + files: z.array(z.object({ url: z.string(), size: z.number().int().positive(), sha512: z.string() }).passthrough()).length(1), + path: z.string().optional(), sha512: z.string().optional(), stagingPercentage: percentage.optional(), +}).passthrough() + +function metadata(contents: string) { + if (Buffer.byteLength(contents) > 1024 * 1024) throw new Error('desktop rollout: metadata exceeds 1 MiB') + try { return metadataSchema.parse(load(contents)) } + // YAML parser details may include raw remote contents; keep them out of publication diagnostics. + catch { throw new Error('desktop rollout: invalid update metadata') } +} + +/** Validated immutable update identity; no installer bytes are read. */ +export interface DesktopRolloutPlan { + readonly upload: DesktopUploadPlan + readonly expected: string +} + +/** + * Read only the retained update YAML and bind it to its manifest and upload destination. + * @param directory Original attempt's retained directory. + * @param manifest Inventory verified against GitHub identity. + * @param environment Deployment settings loaded from the original machine. + * @returns A plan permitting only this channel metadata object. + */ +export async function prepareDesktopRollout(directory: string, manifest: DesktopRetainedManifest, + environment: NodeJS.ProcessEnv): Promise { + const source = manifest.source + const windows = source.target === 'win-x64' + const platform = windows ? 'win32' : 'darwin' + const update = resolveDesktopUploadConfig({ ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel }, + platform, source.target === 'mac-arm64' ? 'arm64' : 'x64') + if (update.environment !== source.preset) throw new Error('desktop rollout: environment differs from original package') + const filename = desktopUpdateMetadataFilename(source.version, platform, source.channel) + const item = manifest.files.find(file => file.name === filename) + if (!item) throw new Error(`desktop rollout: manifest has no update metadata ${filename}`) + const contents = await readFile(join(directory, filename), 'utf8') + if (item.size !== Buffer.byteLength(contents) + || item.sha512 !== createHash('sha512').update(contents).digest('base64')) { + throw new Error('desktop rollout: retained update metadata differs from manifest') + } + const parsed = metadata(contents) + const payload = `deepseek-harness-${source.version}-${source.target}.${windows ? 'exe' : 'zip'}` + const file = manifest.files.find(entry => entry.name === payload) + const entry = parsed.files[0]! + if (parsed.version !== source.version || entry.url !== payload || !file + || entry.size !== file.size || entry.sha512 !== file.sha512 + || (parsed.path !== undefined && parsed.path !== payload) + || (parsed.sha512 !== undefined && parsed.sha512 !== file.sha512)) { + throw new Error('desktop rollout: update metadata does not identify the retained payload') + } + const url = `${update.origin}/${update.binaryKeyPrefix}/${payload}` + const expected = dump({ ...parsed, files: [{ ...entry, url }], + ...(parsed.path === undefined ? {} : { path: url }) }) + return { expected, upload: { + environment: source.preset, target: source.target, version: source.version, commit: source.commit, dirty: false, + publicUrl: update.publicUrl, bucket: update.bucket, + secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, + artifacts: [{ path: join(directory, filename), filename, key: `${update.keyPrefix}/${filename}`, + contents: expected, contentType: 'application/yaml', channelMetadata: true }], + } } +} + +/** + * Verify current remote metadata against the package, allowing only a different rollout percentage. + * @param plan Approved package identity and channel destination. + * @param remote Current COS metadata object. + * @returns Parsed metadata and effective percentage (100 when absent). + */ +export function verifyDesktopRolloutFeed(plan: DesktopRolloutPlan, remote: DesktopRemoteObject | null) { + if (!remote?.contents) throw new Error('desktop rollout: published update feed is missing') + const actual = metadata(remote.contents) + const expected = metadata(plan.expected) + const { stagingPercentage: current = 100, ...identity } = actual + const { stagingPercentage: _original, ...expectedIdentity } = expected + if (!isDeepStrictEqual(identity, expectedIdentity)) { + throw new Error('desktop rollout: current feed no longer identifies the selected published package') + } + return { metadata: actual, percentage: current } +} + +/** + * Update a published feed under the existing channel lock, or verify it for website-only publication. + * @param plan Metadata-only plan approved for the selected original package. + * @param target Target percentage; undefined only verifies current publication. + * @param operations COS transport and durable audit sink; no binary reads or writes are issued. + * @returns Current and requested percentage; identical requests perform no write. + */ +export async function rolloutDesktopRelease(plan: DesktopRolloutPlan, target: number | undefined, + operations: Pick) { + if (target !== undefined) percentage.parse(target) + const artifact = plan.upload.artifacts[0]! + const before = await operations.transport.read(artifact.key, true) + const current = verifyDesktopRolloutFeed(plan, before) + const next = target ?? current.percentage + if (next < current.percentage) throw new Error('desktop rollout: percentage cannot decrease; inspect the current rollout') + if (next === current.percentage) { + await operations.record({ type: 'object-present', key: artifact.key }) + return { before: current.percentage, after: next } + } + const updated = { ...artifact, contents: dump({ ...current.metadata, stagingPercentage: next }) } + const digest = await desktopArtifactDigest(updated) + const confirmed = await operations.transport.read(artifact.key, true) + if (confirmed?.contents !== before?.contents) throw new Error('desktop rollout: feed changed before write; inspect and retry') + await operations.record({ type: 'put-intent', key: artifact.key, before: before!.contents!, after: updated.contents }) + await operations.transport.put(updated, digest, false) + const actual = await operations.transport.read(artifact.key, true) + if (actual?.size !== digest.size || actual.sha512 !== digest.sha512) { + throw new Error('desktop rollout: written feed is unconfirmed; inspect before retrying') + } + await operations.record({ type: 'put-verified', key: artifact.key }) + return { before: current.percentage, after: next } +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index e6a8d4c7f0..39ff954bf5 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -61,6 +61,8 @@ export interface DesktopUploadPlan { export interface DesktopUploadPlanOptions { /** Publish only the installer at its fixed download URL, replacing the previous object. */ readonly latest?: boolean + /** Percentage of installations eligible for this publication; absent preserves packaged metadata. */ + readonly stagingPercentage?: number | undefined readonly environment?: NodeJS.ProcessEnv readonly repositoryRoot?: string readonly appRoot?: string @@ -221,7 +223,6 @@ export async function createDesktopUploadPlan( throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - if (update.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop upload: stable requires a stable version') const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown @@ -272,6 +273,7 @@ export async function createDesktopUploadPlan( const payloadUrl = `${update.origin}/${binaryPrefix}/${updaterInfo.filename}` const published = { ...metadata, + ...(options.stagingPercentage === undefined ? {} : { stagingPercentage: options.stagingPercentage }), files: [{ ...object(metadata.files[0], `${metadataFilename}.files[0]`), url: payloadUrl }], ...(metadata.path === undefined ? {} : { path: payloadUrl }), } diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index c2563a0edf..d5522e3da9 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -105,7 +105,6 @@ export function createElectronBuilderConfig( // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version const buildVersion = resolveDesktopBuildVersion(env, productVersion) - if (update?.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop package: stable requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 16b8fe2a27..48084d6373 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -76,7 +76,7 @@ export async function signMacOSRuntime( const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` const isNode = path === 'dependencies/node/bin/node' const needsJit = isNode - || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path) + || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/[^/]+$/u.test(path) const entitlementsFile = isNode && arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist' const entitlements = needsJit ? join(import.meta.dirname, entitlementsFile) : undefined diff --git a/apps/desktop/scripts/preflight-desktop-publish.mjs b/apps/desktop/scripts/preflight-desktop-publish.mjs index dfc1dcb4a6..fbe6947398 100644 --- a/apps/desktop/scripts/preflight-desktop-publish.mjs +++ b/apps/desktop/scripts/preflight-desktop-publish.mjs @@ -27,7 +27,7 @@ export function preflightDesktopPublish(repository, inputs, api) { throw new Error('desktop publish: GitHub returned invalid artifact metadata') } for (const { name } of pages.flatMap(page => page.artifacts)) { - if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue + if (!name.replace(/\.zip$/u, '').endsWith(`-${inputs.package_run_id}-${attempt}`)) continue const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned' : /^macos-(?:arm64|x64)-unnotarized-debug-/u.test(name) ? 'unnotarized' : '' const diagnostic = desktopPublishQualificationError(qualification) diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index f881e0d48a..e3d14abbff 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -28,6 +28,11 @@ const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z * @returns Validated local publication context; no network operation is performed. */ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySignatures = verifyRetainedDesktopSignatures) { + const percentage = env.DESKTOP_STAGING_PERCENTAGE?.trim() || '' + if (percentage !== '' && (!/^\d+$/u.test(percentage) || Number(percentage) > 100)) { + throw new Error('desktop publish: staging percentage must be an integer from 0 to 100') + } + const stagingPercentage = percentage === '' ? undefined : Number(percentage) const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: task was dispatched to a different machine') const root = desktopReleasesRoot(env) @@ -48,12 +53,13 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket } : { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket } const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory, + stagingPercentage, packagedProductVersion: source.productVersion, environment: { ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel } }) if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes') await verifySignatures(directory, manifest, config) const approval = createHash('sha256').update(JSON.stringify({ manifest, plan })).digest('hex') if (env.DESKTOP_APPROVED_PACKAGE && env.DESKTOP_APPROVED_PACKAGE !== approval) throw new Error('desktop publish: package or destination changed after approval') - return { route, manifest, config, plan, root, approval } + return { route, manifest, config, plan, root, approval, stagingPercentage } } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { @@ -62,7 +68,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const mode = process.argv[2] if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish') const env = process.env - const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env) + const { route, manifest, config, plan, root, approval, stagingPercentage } = await prepareDesktopPublication(env) if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') if (env.DESKTOP_UPLOAD_WEBSITE === 'true' && route.preset === 'production' && !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || '')) { @@ -84,11 +90,11 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi phase = 'channel lock and publication journal acquisition' await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { const published = await wasDesktopAttemptPublished(recordsRoot, - { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination }) + { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination, stagingPercentage }) const journal = await createDesktopPublicationRecord(recordsRoot, { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown', - destination, operation: 'publish', reason: 'Actions publication', + destination, stagingPercentage, operation: 'publish', reason: 'Actions publication', }) if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${journal.directory}\n`) let status: 'complete' | 'failed' | 'published-tag-pending' = 'failed' diff --git a/apps/desktop/scripts/publish-desktop-rollout-ci.ts b/apps/desktop/scripts/publish-desktop-rollout-ci.ts new file mode 100644 index 0000000000..4ab553b3c3 --- /dev/null +++ b/apps/desktop/scripts/publish-desktop-rollout-ci.ts @@ -0,0 +1,136 @@ +/** Actions entrypoint for promoting an already published Desktop package without full package verification. */ +import { createHash } from 'node:crypto' +import { appendFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { z } from 'zod' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { readRetainedDesktopManifest, retainedDesktopPath } from './desktop-release-store.ts' +import { resolveRetainedDesktopPublication, retainedDesktopPublishApi } from './resolve-retained-desktop-publish-ci.ts' +import { createDesktopPublicationRecord, findDesktopPublication, withDesktopChannelLock } from './desktop-publish-record.ts' +import { prepareDesktopRollout, rolloutDesktopRelease } from './desktop-rollout.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { publishWebsiteCi, websiteApproval } from './publish-website-ci.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' +import { desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs' + +const routeSchema = z.object({ + runId: z.string().regex(/^[1-9]\d*$/u), attempt: z.string().regex(/^[1-9]\d*$/u), + commit: z.string().regex(/^[a-f0-9]{40}$/u), sourceRef: z.string(), runnerName: z.string(), runnerLabel: z.string(), + target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), channel: z.enum(['nightly', 'stable']), + preset: z.enum(['test', 'production']), +}) + +/** + * Prepare approval using only identity, initial publication evidence and retained update metadata. + * @param env Original machine settings, resolved route and manual rollout inputs. + * @returns Metadata-only plan and approval digest; no upload credentials are read. + */ +export async function prepareDesktopRolloutCi(env: NodeJS.ProcessEnv) { + const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) + if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop rollout: original machine required') + const input = env.DESKTOP_ROLLOUT_PERCENTAGE?.trim() || '' + if (input !== '' && (!/^\d+$/u.test(input) || Number(input) > 100)) { + throw new Error('desktop rollout: percentage must be an integer from 0 to 100') + } + const target = input === '' ? undefined : Number(input) + const website = env.DESKTOP_UPLOAD_WEBSITE === 'true' + if (target === undefined && !website) throw new Error('desktop rollout: select a percentage or website publication') + if (website && (route.preset !== 'production' || !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || ''))) { + throw new Error('desktop rollout: website publication requires production and DOWNLOAD_PROD_EO_ZONE_ID') + } + const root = desktopReleasesRoot(env) + const manifest = await readRetainedDesktopManifest(root, route.runId, route.attempt) + const source = manifest.source + for (const field of ['runId', 'attempt', 'commit', 'sourceRef', 'runnerName', 'target', 'channel', 'preset'] as const) { + if (source[field] !== route[field]) throw new Error('desktop rollout: route differs from retained package') + } + if (source.repository !== env.GITHUB_REPOSITORY + || source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) { + throw new Error('desktop rollout: repository or release qualification mismatch') + } + const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset) + const environment = source.preset === 'test' + ? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: config.origin, + DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket } + : { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket } + const plan = await prepareDesktopRollout(retainedDesktopPath(root, route.runId, route.attempt), manifest, environment) + const destination = `${config.bucket}/${plan.upload.publicUrl}${source.channel}` + const recordsRoot = join(dirname(root), 'upload-records') + if (!await findDesktopPublication(recordsRoot, { repository: source.repository, + runId: route.runId, attempt: route.attempt, destination })) { + throw new Error('desktop rollout: initial COS feed publication is not confirmed for this package') + } + const approval = createHash('sha256').update(JSON.stringify({ manifest, plan, target, website, + zoneId: website ? env.DOWNLOAD_PROD_EO_ZONE_ID : undefined })).digest('hex') + return { route, root, recordsRoot, config, manifest, plan, target, website, destination, approval } +} + +/** + * Dispatch a lightweight check, rollout, website upload or website cache recovery. + * @param mode Explicit operation from the trusted workflow. + * @param env Actions environment with approved original package identity. + * @returns Resolves after the selected operation and its durable audit complete. + */ +export async function publishDesktopRolloutCi(mode: 'check' | 'apply' | 'website-upload' | 'website-purge', + env: NodeJS.ProcessEnv): Promise { + if (mode === 'website-purge') { + if (env.DESKTOP_UPLOAD_WEBSITE !== 'true') throw new Error('desktop rollout: website publication was not selected') + // The existing website approval and uploaded journal authorize cache recovery without retained feed files. + await publishWebsiteCi('purge', env) + return + } + const context = await prepareDesktopRolloutCi(env) + const { route, root, recordsRoot, config, manifest, plan, target, website, destination, approval } = context + if (mode === 'check') { + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, + `approval=${approval}\nwebsite-approval=${websiteApproval(manifest, config.bucket)}\nwebsite-destination=${createHash('sha256').update(`${config.bucket}/${route.target}`).digest('hex')}\nlabel=${manifest.source.version} / ${route.channel} / ${route.target} → ${target === undefined ? 'website only' : `${target}%`}\n`) + if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_ROLLOUT_APPROVAL) await appendFile(env.GITHUB_STEP_SUMMARY, + `## 桌面放量与官网发布确认\n\n打包运行:${route.runId} / 第 ${route.attempt} 次;版本:${manifest.source.version};平台:${route.target};环境 / 渠道:${route.preset} / ${route.channel}。\n\n目标放量比例:${target === undefined ? '保持当前比例' : `${target}%`}。执行时从 COS 核对当前比例,不允许降低比例。\n\n官网:${website ? desktopWebsiteInstallerUrl(route.target) + '(上传安装包并刷新缓存)' : '不变'}。${website ? '官网安装包面向所有下载用户,不受自动更新放量比例限制,也不要求放量达到 100%。固定下载地址无防降级保护,发布较早版本会替换较新安装包。' : ''}\n\n已确认首次 COS 发布记录;不重新计算全部安装包哈希或校验签名。\n`) + return + } + if (env.DESKTOP_ROLLOUT_APPROVAL !== approval) throw new Error('desktop rollout: approved inputs or retained metadata changed') + if (mode.startsWith('website-') && !website) throw new Error('desktop rollout: website publication was not selected') + const credentials = await readDesktopUploadCredentials(config) + await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { + const journal = await createDesktopPublicationRecord(recordsRoot, { + repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, + actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown', destination, + operation: 'rollout', reason: mode, stagingPercentage: mode === 'apply' ? target : undefined, + }) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `${mode === 'website-upload' ? 'rollout-record' : 'record'}=${journal.directory}\n`) + try { + const result = await rolloutDesktopRelease(plan, mode === 'apply' ? target : undefined, + { transport: desktopPublishCos(plan.upload, credentials), record: event => journal.record(event) }) + if (mode === 'website-upload') await publishWebsiteCi('upload', env) + await journal.record({ type: 'complete' }) + await journal.finish('complete') + const summary = `Desktop rollout: ${manifest.source.version} / ${route.channel} / ${route.target}: ${result.before}% → ${result.after}%\n` + process.stdout.write(summary) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary) + } catch (error) { + await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, 'desktop rollout') }) + await journal.finish('failed') + throw error + } + }) +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + try { + const mode = process.argv[2] + if (mode === 'route' && process.argv.length === 3) { + if (!process.env.GITHUB_OUTPUT) throw new Error('desktop rollout: Actions output required') + const route = await resolveRetainedDesktopPublication( + process.env, retainedDesktopPublishApi(process.env), readRetainedDesktopManifest) + await appendFile(process.env.GITHUB_OUTPUT, `route=${JSON.stringify(route)}\npreset=${route.preset}\n`) + } else { + const parsed = z.enum(['check', 'apply', 'website-upload', 'website-purge']).parse(mode) + if (process.argv.length !== 3) throw new Error('desktop rollout: expected one operation') + await publishDesktopRolloutCi(parsed, process.env) + } + } catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'desktop rollout'))}\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts index d892fe6906..1f2526e78a 100644 --- a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts @@ -10,13 +10,15 @@ import { resolveDesktopPublishRoute } from './desktop-publish-route.ts' * Verify a retained package against GitHub selection evidence, source history and required reviewers. * @param env Selected attempt and current runner context; no signing or upload credentials are needed. * @param api Read-only GitHub API used for source and Environment checks. + * @param readManifest Inventory verifier; metadata-only consumers supply the manifest reader. * @returns Complete route whose deployment environment comes from the retained manifest. */ -export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, api: (path: string) => unknown) { +export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, api: (path: string) => unknown, + readManifest = verifyRetainedDesktopRelease) { const selection = z.object({ run: z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive() }).passthrough(), jobs: z.array(z.unknown()), routes: z.unknown(), runnerName: z.string() }).parse(JSON.parse(env.DESKTOP_PACKAGE_SELECTION || '{}')) if (!env.GITHUB_REPOSITORY || env.RUNNER_NAME !== selection.runnerName) throw new Error('desktop publish: task was dispatched to a different machine') - const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), String(selection.run.id), String(selection.run.run_attempt)) + const manifest = await readManifest(desktopReleasesRoot(env), String(selection.run.id), String(selection.run.run_attempt)) const route = resolveDesktopPublishRoute(env.GITHUB_REPOSITORY, selection.run, manifest, selection.jobs, selection.routes) if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: retained package belongs to another machine') const repository = env.GITHUB_REPOSITORY @@ -37,16 +39,26 @@ export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, return route } -if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { - const env = process.env - if (!env.GITHUB_OUTPUT || !env.GH_TOKEN) throw new Error('desktop publish: Actions output and API token required') - const route = await resolveRetainedDesktopPublication(env, async (path) => { +/** + * Create the read-only GitHub verifier used on self-hosted publication machines. + * @param env Actions API origin and token; no gh installation is required. + * @returns A bounded JSON reader whose responses are validated by the route resolver. + */ +export function retainedDesktopPublishApi(env: NodeJS.ProcessEnv): (path: string) => Promise { + if (!env.GH_TOKEN) throw new Error('desktop publish: API token required') + return async (path) => { const response = await fetch(`${env.GITHUB_API_URL || 'https://api.github.com'}/${path}`, { headers: { Authorization: `Bearer ${env.GH_TOKEN}`, Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(60_000), }) if (!response.ok) throw new Error(`desktop publish: GitHub verification request failed (HTTP ${response.status})`) return response.json() - }) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + const env = process.env + if (!env.GITHUB_OUTPUT || !env.GH_TOKEN) throw new Error('desktop publish: Actions output and API token required') + const route = await resolveRetainedDesktopPublication(env, retainedDesktopPublishApi(env)) await appendFile(env.GITHUB_OUTPUT, `route=${JSON.stringify(route)}\npreset=${route.preset}\n`) } diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts index e6afd574da..a67bc28c2a 100644 --- a/apps/desktop/scripts/smoke-prepared-runtime.ts +++ b/apps/desktop/scripts/smoke-prepared-runtime.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { promisify } from 'node:util' -import { runtimeArchivePath } from '../../desktop-host/src/office-engine.ts' +import { runtimeArchivePath } from '../../desktop-host/src/cli.ts' import { desktopNodeEnvironment } from '../src/node-environment.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index bbc2d3e7ae..893969c6b8 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -13,7 +13,8 @@ import { createPluginProfile } from '../src/project-manager.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' /** - * Check CLI package operations, Host startup, its frontend, external plugins and real Office-to-PDF conversion. + * Check CLI package operations, Host startup, its frontend, external plugins, packaged Office skill loading, + * and real Office rendering, conversion, and recalculation. * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. @@ -85,8 +86,7 @@ export function apply(ctx) { })) writeFileSync(join(plugin, 'index.js'), ` import { Context } from '@deepseek-ai/cordis' -import { inspect, promisify } from 'node:util' -import { execFile } from 'node:child_process' +import { inspect } from 'node:util' import { readFile } from 'node:fs/promises' export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') @@ -101,18 +101,16 @@ export function apply(ctx) { response.end(inspect(error, { depth: 5 })) } } })) - ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli', + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-skills', async handler(_request, response) { try { - const skill = await ctx.skills.get('office-export-pdf') - const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1] - if (json === undefined) throw new Error('Office skill did not supply CLI paths') - const { libreofficeKit: { node, cli } } = JSON.parse(json) - const options = { cwd: ${JSON.stringify(home)}, env: { ...process.env, PATH: '' }, timeout: 120_000 } - const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options) - const output = ${JSON.stringify(join(home, 'cli.pdf'))} - await promisify(execFile)(node, [cli, 'convert', '--input', ${JSON.stringify(join(home, 'input.docx'))}, '--output', output], options) - response.end(JSON.stringify({ capabilities: JSON.parse(capabilities.stdout), pdf: (await readFile(output)).toString('base64') })) + const names = ['office', 'office-docx', 'office-pptx', 'office-xlsx'] + const listed = new Set((await ctx.skills.list()).map(skill => skill.name)) + const loaded = await Promise.all(names.map(async name => { + const skill = listed.has(name) ? await ctx.skills.get(name) : undefined + return { name, path: skill?.path ?? null, bytes: skill?.content.length ?? 0 } + })) + response.end(JSON.stringify(loaded)) } catch (error) { response.statusCode = 500 response.end(inspect(error, { depth: 5 })) @@ -123,7 +121,7 @@ export function apply(ctx) { async handler(_request, response) { try { const bytes = Buffer.from(input.bytes, 'base64') - const result = await ctx.officeToPdf.convert({ extension: input.extension, priority: 'foreground', + const result = await ctx.officeKit.convert({ extension: input.extension, priority: 'foreground', source: { key: 'desktop-smoke-' + input.extension, version: 'fixture', bytes: bytes.length, async read() { return { bytes, version: 'fixture' } } } }) response.end(Buffer.from(result.pdf)) @@ -132,10 +130,35 @@ export function apply(ctx) { response.end(inspect(error, { depth: 5 })) } } })) + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-operations/' + input.extension, + async handler(_request, response) { + try { + const bytes = Buffer.from(input.bytes, 'base64') + const source = (operation) => ({ key: 'desktop-smoke-' + operation + '-' + input.extension, version: 'fixture', bytes: bytes.length, + async read() { return { bytes, version: 'fixture' } } }) + const rendered = await ctx.officeKit.renderImages({ extension: input.extension, source: source('images'), + maxImages: 4, maxPixels: 16777216, maxDimension: 8192, maxBytes: 64 * 1024 * 1024 }) + const format = { docx: 'odt', xlsx: 'ods', pptx: 'odp' }[input.extension] + const converted = await ctx.officeKit.convertTo({ extension: input.extension, format, + source: source('convert'), output: { owner: 'desktop-smoke', name: 'converted.' + format } }) + const recalculated = input.extension === 'xlsx' + ? await ctx.officeKit.recalculate({ extension: 'xlsx', format: 'xlsx', source: source('recalculate'), + output: { owner: 'desktop-smoke', name: 'recalculated.xlsx' } }) + : undefined + response.end(JSON.stringify({ + images: rendered.images.map(image => Buffer.from(image.png.subarray(0, 8)).toString('hex')), + converted: (await readFile(converted.path)).subarray(0, 4).toString('hex'), + recalculated: recalculated === undefined ? null : (await readFile(recalculated.path)).subarray(0, 4).toString('hex'), + })) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, pluginManager]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeKit, pluginManager, skills]\n') cpSync(plugin, join(profile, 'node_modules', pluginName), { recursive: true }) const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { dependencies?: Record @@ -165,6 +188,13 @@ export function apply(ctx) { if (!installation.ok || !installed.ok || await installed.text() !== 'installed plugin ready') { throw new Error(`desktop runtime: bundled pnpm plugin installation failed: ${installationResult}`) } + const skills = await fetch(new URL('/desktop-smoke-skills', ready.url), { headers: { cookie }, signal: AbortSignal.timeout(120_000) }) + if (!skills.ok) throw new Error(`desktop runtime: Office skill loading failed: ${await skills.text()}`) + const loadedSkills = await skills.json() as Array<{ name: string; path: string | null; bytes: number }> + if (loadedSkills.some(skill => skill.path === null || skill.bytes === 0)) { + throw new Error(`desktop runtime: packaged Office skills are not loadable: ${JSON.stringify(loadedSkills)}`) + } + console.log('desktop runtime: packaged Office skills listed and loaded') for (const { extension } of inputs) { const converted = await fetch(new URL(`/desktop-smoke-office/${extension}`, ready.url), { headers: { cookie }, signal: AbortSignal.timeout(120_000), @@ -176,15 +206,19 @@ export function apply(ctx) { throw new Error(`desktop runtime: invalid ${extension} PDF output`) } } - const cliResponse = await fetch(new URL('/desktop-smoke-office-cli', ready.url), { - headers: { cookie }, signal: AbortSignal.timeout(120_000), - }) - if (!cliResponse.ok) throw new Error(`desktop runtime: skill CLI failed: ${await cliResponse.text()}`) - const cliResult = await cliResponse.json() as { capabilities: { runtime: { cliPath: string } }; pdf: string } - if (!cliResult.capabilities.runtime.cliPath.endsWith('cli.js') || Buffer.from(cliResult.pdf, 'base64').subarray(0, 5).toString() !== '%PDF-') { - throw new Error('desktop runtime: skill CLI did not return capabilities and a PDF') + for (const { extension } of inputs) { + const operations = await fetch(new URL(`/desktop-smoke-office-operations/${extension}`, ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(240_000), + }) + if (!operations.ok) throw new Error(`desktop runtime: ${extension} Office operations failed: ${await operations.text()}`) + const result = await operations.json() as { images: string[]; converted: string; recalculated: string | null } + // PNG images, then ZIP containers for OpenDocument and XLSX outputs. + if (result.images.length === 0 || result.images.some(signature => signature !== '89504e470d0a1a0a') + || result.converted !== '504b0304' || (extension === 'xlsx' ? result.recalculated !== '504b0304' : result.recalculated !== null)) { + throw new Error(`desktop runtime: invalid ${extension} Office operation output: ${JSON.stringify(result)}`) + } } - console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed') + console.log('desktop runtime: DOCX, XLSX, PPTX to PDF, image rendering, conversion, and recalculation passed') } finally { clearTimeout(timer) try { await host.stop() } finally { diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 338f93bda4..8e26fb8d47 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -182,8 +182,7 @@ export class DesktopHostProcess { * @param inspectPort - Optional loopback inspector port for workspace development. * @param environment - Environment inherited by the Host and its plugin subprocesses. * @param onFailure - Receives the first unexpected child failure, including after readiness. - * @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling - * `office-skills` resources fail Host startup. + * @param primaryRuntime - Optional bundled dependency payload; the Host exposes it to its profile as `DSH_PRIMARY_RUNTIME`. * @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations. * @param onPlatformSession - Private credential updates for embedded Platform views. * @param onPluginOperation - Optional diagnostic sink; malformed records are ignored without failing the Host. @@ -215,7 +214,8 @@ export class DesktopHostProcess { entry, this.runtimeDir, this.projectDir, - this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'), + // Empty asks the Host for the payload beside its runtime directory, so that layout has one owner. + this.primaryRuntime ?? '', ...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin], ], { cwd: this.projectDir, diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index bddf39b9f8..050548e879 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -37,6 +37,8 @@ export const en = { hideApplication: 'Hide DeepSeek Harness', hideOtherApplications: 'Hide Others', showAllApplications: 'Show All', + substitutionsMenu: 'Substitutions', + speechMenu: 'Speech', quitApplication: 'Quit DeepSeek Harness', openApplication: 'Open DeepSeek Harness', quit: 'Quit', @@ -228,6 +230,8 @@ export const zh = { hideApplication: '隐藏 DeepSeek Harness', hideOtherApplications: '隐藏其他', showAllApplications: '显示全部', + substitutionsMenu: '替换', + speechMenu: '语音', quitApplication: '退出 DeepSeek Harness', openApplication: '打开 DeepSeek Harness', quit: '退出', @@ -417,6 +421,8 @@ export const ja = { hideApplication: 'DeepSeek Harness を隠す', hideOtherApplications: 'ほかを隠す', showAllApplications: 'すべて表示', + substitutionsMenu: '自動置換', + speechMenu: 'スピーチ', quitApplication: 'DeepSeek Harness を終了', openApplication: 'DeepSeek Harness を開く', quit: '終了', @@ -606,6 +612,8 @@ export const es = { hideApplication: 'Ocultar DeepSeek Harness', hideOtherApplications: 'Ocultar otros', showAllApplications: 'Mostrar todo', + substitutionsMenu: 'Sustituciones', + speechMenu: 'Voz', quitApplication: 'Salir de DeepSeek Harness', openApplication: 'Abrir DeepSeek Harness', quit: 'Salir', @@ -795,6 +803,8 @@ export const fr = { hideApplication: 'Masquer DeepSeek Harness', hideOtherApplications: 'Masquer les autres', showAllApplications: 'Afficher tout', + substitutionsMenu: 'Substitutions', + speechMenu: 'Parole', quitApplication: 'Quitter DeepSeek Harness', openApplication: 'Ouvrir DeepSeek Harness', quit: 'Quitter', @@ -984,6 +994,8 @@ export const de = { hideApplication: 'DeepSeek Harness ausblenden', hideOtherApplications: 'Andere ausblenden', showAllApplications: 'Alle einblenden', + substitutionsMenu: 'Ersetzungen', + speechMenu: 'Sprachausgabe', quitApplication: 'DeepSeek Harness beenden', openApplication: 'DeepSeek Harness öffnen', quit: 'Beenden', @@ -1173,6 +1185,8 @@ export const ru = { hideApplication: 'Скрыть DeepSeek Harness', hideOtherApplications: 'Скрыть других', showAllApplications: 'Показать все', + substitutionsMenu: 'Замены', + speechMenu: 'Речь', quitApplication: 'Выйти из DeepSeek Harness', openApplication: 'Открыть DeepSeek Harness', quit: 'Выйти', @@ -1362,6 +1376,8 @@ export const pt = { hideApplication: 'Ocultar DeepSeek Harness', hideOtherApplications: 'Ocultar outros', showAllApplications: 'Mostrar tudo', + substitutionsMenu: 'Substituições', + speechMenu: 'Fala', quitApplication: 'Sair de DeepSeek Harness', openApplication: 'Abra DeepSeek Harness', quit: 'Sair', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1a2488fe9d..dd3141228b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -37,6 +37,7 @@ import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { readDeviceInfo } from './device-info.ts' import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' +import type { DesktopMessages } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' @@ -1141,8 +1142,31 @@ async function main(): Promise { // its standard menus and application hide commands declared explicitly. // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' + // A copy of Electron 44's macOS Edit menu that binds Paste and Match Style to Shift+Command+V + // (#7782) and keeps Electron's Option+Shift+Command+V as a hidden alias. + const macEditMenu = (messages: DesktopMessages): MenuItemConstructorOptions => ({ role: 'editMenu', submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'pasteAndMatchStyle', accelerator: 'Shift+Command+V' }, + { role: 'pasteAndMatchStyle', accelerator: 'Option+Shift+Command+V', visible: false, acceleratorWorksWhenHidden: true }, + { role: 'delete' }, + { role: 'selectAll' }, + { type: 'separator' }, + { label: messages.substitutionsMenu, submenu: [ + { role: 'showSubstitutions' }, + { type: 'separator' }, + { role: 'toggleSmartQuotes' }, + { role: 'toggleSmartDashes' }, + { role: 'toggleTextReplacement' }, + ] }, + { label: messages.speechMenu, submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }] }, + ] }) const platformMenus = (): MenuItemConstructorOptions[] => darwin - ? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }] + ? [shortcuts.fileMenu(currentDesktopLocale().messages), macEditMenu(currentDesktopLocale().messages), { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication }, diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 29cf2d88f3..475a560667 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -276,7 +276,6 @@ export class DesktopUpdateCoordinator { this.cancellation = result.cancellationToken const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') - if (this.channel === 'stable' && version.includes('-')) throw new Error('desktop update: stable feed contains a prerelease') this.release = result.isUpdateAvailable && gt(version, this.currentVersion()) ? { kind: 'checked', version } : { kind: 'none' } return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) diff --git a/apps/desktop/src/updates/electron-updater/channel.ts b/apps/desktop/src/updates/electron-updater/channel.ts index 06f07627b2..a67dfdacde 100644 --- a/apps/desktop/src/updates/electron-updater/channel.ts +++ b/apps/desktop/src/updates/electron-updater/channel.ts @@ -2,7 +2,7 @@ import type { AppUpdater } from 'electron-updater' /** - * Select a feed without permitting downgrades or prereleases on stable. + * Select the operator-managed feed, accepting prereleases while preventing downgrades. * @param updater - Updater whose prior operations have settled. * @param channel - Selected distribution channel. */ @@ -10,5 +10,5 @@ export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 's updater.channel = channel // The library channel setter enables downgrades. updater.allowDowngrade = false - updater.allowPrerelease = channel === 'nightly' + updater.allowPrerelease = true } diff --git a/apps/desktop/tests/desktop-channel-version.spec.ts b/apps/desktop/tests/desktop-channel-version.spec.ts new file mode 100644 index 0000000000..99293c650c --- /dev/null +++ b/apps/desktop/tests/desktop-channel-version.spec.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { desktopCiVersion } from '../scripts/desktop-ci-version.mjs' +import { desktopBuildVersionPrefix } from '../scripts/desktop-build-version.mjs' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' + +it.each(['nightly', 'stable'] as const)('resolves every CI version mode independently of %s', (channel) => { + const now = new Date('2026-11-03T00:00:00Z') + for (const version of ['1.2.3', '1.2.3-alpha.1', '1.2.3-rc.1']) { + const env = { DSH_DESKTOP_UPDATE_CHANNEL: channel, GITHUB_RUN_NUMBER: '7', GITHUB_RUN_ATTEMPT: '2' } + const generated = `${desktopBuildVersionPrefix(version)}20261103.7.2` + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'follow-dsh' }, version, now)).toBe(version) + for (const preset of ['test', 'production']) { + expect(desktopCiVersion({ ...env, DSH_PACKAGING_PRESET: preset, PACKAGE_VERSION_MODE: 'build-number' }, version, now)).toBe(generated) + } + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'product' }, version, now)).toBe(version) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'auto' }, version, now)).toBe(generated) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: generated }, version, now)).toBe(generated) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'production' }, version, now)).toBe(version) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'test' }, version, now)).toBe(generated) + expect(() => desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: 'invalid' }, version, now)).toThrow('Invalid build version') + } +}) + +it.each(['arm64', 'x64'] as const)('embeds stable in a prerelease macOS %s package and provider', (arch) => { + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + const version = `${desktopBuildVersionPrefix(manifest.version)}20261103.1` + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.channel-test', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DSH_DESKTOP_UPDATE_CHANNEL: 'stable', + DSH_DESKTOP_BUILD_VERSION: version, + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_KEYCHAIN_PROFILE: 'channel-test', + }, 'darwin', arch) + expect(config.extraMetadata).toMatchObject({ version, dshDesktopUpdateChannel: 'stable' }) + expect(config.publish).toEqual([{ provider: 'generic', url: `https://download.deepseek.com/dsh-desk/feeds/mac-${arch}/`, channel: 'stable' }]) +}) + +it('accepts a stable prerelease in an unsigned Windows x64 configuration', () => { + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + const version = `${desktopBuildVersionPrefix(manifest.version)}20261103.1` + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.channel-test', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DSH_DESKTOP_UPDATE_CHANNEL: 'stable', + DSH_DESKTOP_BUILD_VERSION: version, + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_UNSIGNED: '1', + }, 'win32', 'x64') + expect(config.extraMetadata).toMatchObject({ version }) + expect(config.publish).toBeNull() +}) diff --git a/apps/desktop/tests/desktop-cli.spec.ts b/apps/desktop/tests/desktop-cli.spec.ts index e358df8657..020f3d7953 100644 --- a/apps/desktop/tests/desktop-cli.spec.ts +++ b/apps/desktop/tests/desktop-cli.spec.ts @@ -3,11 +3,9 @@ import { delimiter, join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { runCli } from '@deepseek-ai/dsh/lib/bin.js' -import { runDesktopCli } from '../../desktop-host/src/cli.ts' -import { installOfficeEngineResolution } from '../../desktop-host/src/office-engine.ts' +import { runDesktopCli, runtimeArchivePath } from '../../desktop-host/src/cli.ts' vi.mock('@deepseek-ai/dsh/lib/bin.js', () => ({ runCli: vi.fn() })) -vi.mock('../../desktop-host/src/office-engine.ts', () => ({ installOfficeEngineResolution: vi.fn() })) afterEach(() => { vi.unstubAllEnvs() @@ -16,12 +14,10 @@ afterEach(() => { it.each(['system-bin', undefined])('dispatches with bundled pnpm when PATH is %s', async (path) => { vi.stubEnv('PATH', path) - const runtime = join('Application 中文 with spaces', 'resources', 'app.asar', 'dsh') const support = join('Application 中文 with spaces', 'resources', 'runtime') - await runDesktopCli(runtime, support) + await runDesktopCli(support) - expect(installOfficeEngineResolution).toHaveBeenCalledExactlyOnceWith(runtime) expect(runCli).toHaveBeenCalledExactlyOnceWith({ manageDesktopProfile: true, packageManager: { @@ -35,3 +31,10 @@ it.each(['system-bin', undefined])('dispatches with bundled pnpm when PATH is %s }, }) }) + +it('locates the archive of an ASAR-contained runtime and leaves a prepared runtime without one', () => { + const resources = join('Application 中文', 'resources') + expect(runtimeArchivePath(join(resources, 'app.asar', 'dsh'))).toBe(join(resources, 'app.asar')) + expect(runtimeArchivePath(join(resources, 'app.asar.unpacked', 'dsh'))).toBeUndefined() + expect(runtimeArchivePath(join(resources, 'dsh'))).toBeUndefined() +}) diff --git a/apps/desktop/tests/desktop-publication-failure.spec.ts b/apps/desktop/tests/desktop-publication-failure.spec.ts index 80daeab539..721b839ba8 100644 --- a/apps/desktop/tests/desktop-publication-failure.spec.ts +++ b/apps/desktop/tests/desktop-publication-failure.spec.ts @@ -48,11 +48,29 @@ it('omits malformed provider receipt fields and never copies raw errors into COS it('prints structured archival failure through the real CLI', () => { const result = spawnSync(process.execPath, ['--import', 'tsx/esm', fileURLToPath(new URL('../scripts/archive-desktop-ci.ts', import.meta.url))], { - encoding: 'utf8', timeout: 30_000, env: { ...process.env, RUNNER_NAME: '', DESKTOP_PACKAGE_RUNNER: '' }, + encoding: 'utf8', timeout: 30_000, env: { ...process.env, DESKTOP_ARCHIVE_NAME: '', GITHUB_OUTPUT: '' }, }) expect(result.error).toBeUndefined() expect(result.signal).toBeNull() expect(result.status).toBe(1) expect(JSON.parse(result.stderr)).toEqual({ phase: 'artifact archival', code: 'validation', - message: 'desktop archival: wrong original machine' }) + message: 'desktop archival: invalid ZIP output settings' }) +}) + + +it.each(['percentage cannot decrease; inspect the current rollout', 'feed changed before write; inspect and retry', + 'written feed is unconfirmed; inspect before retrying'])('retains rollout recovery instructions: %s', (message) => { + expect(desktopPublicationFailure(new Error(`desktop rollout: ${message}`), 'desktop rollout')) + .toEqual({ phase: 'desktop rollout', code: 'validation', message: `desktop rollout: ${message}` }) +}) + +it.each([['check', 'extra'], ['route']])('prints actionable rollout CLI failures for %s', (...args) => { + const result = spawnSync(process.execPath, ['--import', 'tsx/esm', + fileURLToPath(new URL('../scripts/publish-desktop-rollout-ci.ts', import.meta.url)), ...args], { + encoding: 'utf8', timeout: 30_000, env: { ...process.env, GITHUB_OUTPUT: '', GH_TOKEN: '' }, + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(1) + expect(JSON.parse(result.stderr)).toMatchObject({ phase: 'desktop rollout', code: 'validation', + message: args[0] === 'route' ? 'desktop rollout: Actions output required' : 'desktop rollout: expected one operation' }) }) diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 04402925a5..dd6e2e7f41 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -1,3 +1,4 @@ +import { load } from 'js-yaml' import { createHash } from 'node:crypto' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -61,10 +62,25 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu expect(summary).not.toContain('example.com//') expect(summary).not.toContain(root) expect(summary).not.toContain('absent-credentials') + for (const percentage of ['0', '10', '100']) { + const staged = await prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage }, signature) + const stagedFeed = staged.plan.artifacts.find(file => file.channelMetadata)! + expect(load(stagedFeed.contents!)).toMatchObject({ stagingPercentage: Number(percentage) }) + expect(staged.approval).not.toBe(prepared.approval) + expect(await desktopPublicationSummary(staged.manifest.source, staged.plan, staged.approval, false)).toContain(`${percentage}%`) + await expect(prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage, + DESKTOP_APPROVED_PACKAGE: prepared.approval }, signature)).rejects.toThrow(/changed after approval/u) + } + for (const percentage of ['-1', '101', '1.5', 'NaN', 'Infinity', '1e1']) { + await expect(prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage }, signature)) + .rejects.toThrow(/integer from 0 to 100/u) + } + const blank = await prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: ' ' }, signature) + expect(blank.approval).toBe(prepared.approval) await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u) await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u) await writeFile(join(retained, `${base}.${extension}`), 'changed') await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u) - expect(signatureChecks).toBe(2) + } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/desktop/tests/desktop-publish-preflight.spec.ts b/apps/desktop/tests/desktop-publish-preflight.spec.ts index f6c99ae280..755a0ce8a6 100644 --- a/apps/desktop/tests/desktop-publish-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publish-preflight.spec.ts @@ -4,6 +4,8 @@ import { preflightDesktopPublish } from '../scripts/preflight-desktop-publish.mj it.each([ ['macos-x64-unnotarized-debug-1.0.0-42-2', '跳过了公证'], ['macos-arm64-unnotarized-debug-1.0.0-42-2', '跳过了公证'], + ['macos-arm64-unnotarized-debug-1.0.0-42-2.zip', '跳过了公证'], + ['windows-x64-unsigned-1.0.0-42-2.zip', '未签名'], ['windows-x64-unsigned-1.0.0-42-2', '未签名'], ])('rejects %s from paginated artifact metadata with rebuilding instructions', (name, reason) => { const paths: string[] = [] diff --git a/apps/desktop/tests/desktop-publish-record.spec.ts b/apps/desktop/tests/desktop-publish-record.spec.ts index 87646edbe1..287af3e4a2 100644 --- a/apps/desktop/tests/desktop-publish-record.spec.ts +++ b/apps/desktop/tests/desktop-publish-record.spec.ts @@ -52,10 +52,10 @@ it('persists intent, before/after bytes and terminal state outside the checkout' } finally { await rm(root, { recursive: true, force: true }) } }) -it('recognizes feed completion even when the later tag failed', async () => { +it.each([undefined, 10])('recognizes feed completion with percentage %s even when the later tag failed', async (stagingPercentage) => { const root = await mkdtemp(join(tmpdir(), 'desktop-recovery-')) try { - const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac' } + const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac', stagingPercentage } const journal = await createDesktopPublicationRecord(root, { ...identity, actor: 'operator', operation: 'publish', reason: 'publish' }) expect(await wasDesktopAttemptPublished(root, identity)).toBe(false) await journal.record({ type: 'feeds-complete' }) @@ -63,6 +63,8 @@ it('recognizes feed completion even when the later tag failed', async () => { await journal.finish('published-tag-pending') expect(await wasDesktopAttemptPublished(root, identity)).toBe(true) expect(await wasDesktopAttemptPublished(root, { ...identity, attempt: '1' })).toBe(false) + await expect(wasDesktopAttemptPublished(root, { ...identity, stagingPercentage: 20 })) + .rejects.toThrow(/differs from the completed publication/u) } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/desktop/tests/desktop-publish.spec.ts b/apps/desktop/tests/desktop-publish.spec.ts index bc5cba2166..cf88751230 100644 --- a/apps/desktop/tests/desktop-publish.spec.ts +++ b/apps/desktop/tests/desktop-publish.spec.ts @@ -81,6 +81,21 @@ it.each(['binary', 'same-version-feed', 'newer-feed'] as const)('stops on %s con expect(f.writes).toHaveLength(0) }) +it.each(['1.0.0-rc.1', '1.0.0-test.20261104.1.1'])('rejects %s before writing when stable already serves its release version', async (version) => { + const f = fixture('production') + const plan: DesktopUploadPlan = { + ...f.plan, + version, + artifacts: f.plan.artifacts.map(artifact => artifact.channelMetadata + ? { ...artifact, filename: 'stable.yml', key: 'feed/stable.yml', contents: `version: ${version}\n` } + : artifact), + } + f.objects.set('feed/stable.yml', observed('version: 1.0.0\n')) + await expect(publishDesktopRelease(plan, f.operations)).rejects.toMatchObject({ code: 'superseded' }) + expect(f.writes).toEqual([]) + expect(f.tags()).toBe(0) +}) + it('rejects a files-only plan and a production build without clean commit evidence', async () => { const f = fixture('production') await expect(publishDesktopRelease({ ...f.plan, artifacts: f.plan.artifacts.slice(0, 2) }, f.operations)).rejects.toMatchObject({ code: 'invalid-plan' }) diff --git a/apps/desktop/tests/desktop-rollout.spec.ts b/apps/desktop/tests/desktop-rollout.spec.ts new file mode 100644 index 0000000000..8109327090 --- /dev/null +++ b/apps/desktop/tests/desktop-rollout.spec.ts @@ -0,0 +1,408 @@ +import { execFile, spawnSync } from 'node:child_process' +import { createServer } from 'node:http' +import { promisify } from 'node:util' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { desktopBuildRecordFilename, desktopWebsiteInstallerUrl } from '../scripts/desktop-auto-update-environment.mjs' +import { dump, load } from 'js-yaml' +import { expect, it, vi } from 'vitest' +import { prepareDesktopRollout, rolloutDesktopRelease } from '../scripts/desktop-rollout.ts' +import * as cosTransport from '../scripts/desktop-publish-cos.ts' +import * as websitePublisher from '../scripts/publish-website-ci.ts' +import { prepareDesktopRolloutCi, publishDesktopRolloutCi } from '../scripts/publish-desktop-rollout-ci.ts' +import { createDesktopPublicationRecord } from '../scripts/desktop-publish-record.ts' +import { readRetainedDesktopManifest, type DesktopRetainedManifest } from '../scripts/desktop-release-store.ts' +import { resolveRetainedDesktopPublication } from '../scripts/resolve-retained-desktop-publish-ci.ts' +import type { DesktopPublishEvent, DesktopRemoteObject, DesktopPublishTransport } from '../scripts/desktop-publish.ts' + +const digest = (contents: string) => ({ + size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64'), +}) + +async function fixture(target: 'mac-arm64' | 'mac-x64' | 'win-x64' = 'mac-arm64', + preset: 'production' | 'test' = 'production', channel: 'nightly' | 'stable' = 'nightly') { + const root = await mkdtemp(join(tmpdir(), 'desktop-rollout-')) + try { + const directory = join(root, 'releases', '42', '1') + await mkdir(directory, { recursive: true }) + const version = '0.2.2-rc.1' + const payload = `deepseek-harness-${version}-${target}.${target === 'win-x64' ? 'exe' : 'zip'}` + const contents = dump({ version, files: [{ url: payload, ...digest('installer') }], + path: payload, sha512: digest('installer').sha512, releaseDate: '2026-11-04T00:00:00.000Z' }) + const filename = `${channel}${target === 'win-x64' ? '' : '-mac'}.yml` + const manifest: DesktopRetainedManifest = { schemaVersion: 1, source: { + repository: 'owner/repo', runId: '42', attempt: '1', commit: 'a'.repeat(40), workflowCommit: 'b'.repeat(40), + sourceRef: 'dsh-v0.2.2-rc.1', runnerName: 'runner', target, preset, channel, + productVersion: version, version, testReleaseId: preset === 'test' ? 'a'.repeat(32) : null, qualification: target === 'win-x64' ? 'signed' : 'notarized', + }, files: [{ name: filename, ...digest(contents) }, { name: payload, ...digest('installer') }] } + await writeFile(join(directory, filename), contents) + await writeFile(join(directory, 'manifest.json'), JSON.stringify(manifest)) + const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: preset, DOWNLOAD_PROD_COS_BUCKET: 'bucket-123', + DOWNLOAD_TEST_ORIGIN: 'https://test.example.com', DOWNLOAD_TEST_COS_BUCKET: 'test-123', DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) } + const plan = await prepareDesktopRollout(directory, manifest, environment) + return { root, directory, manifest, plan, filename, environment } + } catch (error) { await rm(root, { recursive: true, force: true }); throw error } +} + +function transport(contents: string) { + let remote: DesktopRemoteObject | null = { contents, ...digest(contents) } + const reads: string[] = [] + const writes: string[] = [] + const events: DesktopPublishEvent[] = [] + const api: DesktopPublishTransport = { + async read(key, channel) { expect(channel).toBe(true); reads.push(key); return remote }, + async put(artifact, expected, forbidden) { + expect(forbidden).toBe(false) + expect(artifact.channelMetadata).toBe(true) + expect(digest(artifact.contents!)).toEqual(expected) + writes.push(artifact.contents!) + remote = { contents: artifact.contents!, ...expected } + }, + } + return { api, reads, writes, events, record: async (event: DesktopPublishEvent) => { events.push(event) }, + replace(value: DesktopRemoteObject | null) { remote = value }, + } +} + +it.each([ + ['mac-arm64', 'production', 'nightly'], ['win-x64', 'production', 'nightly'], + ['mac-x64', 'production', 'stable'], ['mac-arm64', 'test', 'nightly'], ['win-x64', 'test', 'stable'], +] as const)('promotes %s %s %s from 10 to 30 to 100 without installer files or binary requests', async (target, preset, channel) => { + const f = await fixture(target, preset, channel) + try { + const original = load(f.plan.expected) as Record + const mock = transport(dump({ ...original, stagingPercentage: 10 })) + const operations = { transport: mock.api, record: mock.record } + expect(await rolloutDesktopRelease(f.plan, 30, operations)).toEqual({ before: 10, after: 30 }) + expect(await rolloutDesktopRelease(f.plan, 100, operations)).toEqual({ before: 30, after: 100 }) + expect(await rolloutDesktopRelease(f.plan, 100, operations)).toEqual({ before: 100, after: 100 }) + expect(await rolloutDesktopRelease(f.plan, undefined, operations)).toEqual({ before: 100, after: 100 }) + expect(mock.writes).toHaveLength(2) + expect(new Set(mock.reads)).toEqual(new Set([f.plan.upload.artifacts[0]!.key])) + for (const [index, value] of mock.writes.entries()) { + expect(load(value)).toEqual({ ...original, stagingPercentage: index === 0 ? 30 : 100 }) + } + expect(mock.events.filter(event => event.type === 'put-intent')).toHaveLength(2) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it.each(['missing', 'version', 'url', 'hash', 'size', 'policy', 'percentage', 'decrease', 'race', 'unconfirmed'] as const)( + 'rejects %s without blindly retrying a write', async (scenario) => { + const f = await fixture() + try { + const metadata = load(f.plan.expected) as { + version: string + files: Array<{ url: string; sha512: string; size: number }> + stagingPercentage?: number + [key: string]: unknown + } + metadata.stagingPercentage = 10 + if (scenario === 'version') metadata.version = '9.0.0' + if (scenario === 'url') metadata.files[0]!.url = 'https://example.com/other.zip' + if (scenario === 'hash') metadata.files[0]!.sha512 = digest('different').sha512 + if (scenario === 'size') metadata.files[0]!.size++ + if (scenario === 'policy') metadata.minimumVersion = '9.0.0' + if (scenario === 'percentage') metadata.stagingPercentage = 101 + const mock = transport(dump(metadata)) + if (scenario === 'missing') mock.replace(null) + let reads = 0 + const api = { ...mock.api, async read(key: string, channel: boolean) { + reads++ + if (scenario === 'race' && reads === 2) mock.replace(null) + if (scenario === 'unconfirmed' && reads === 3) mock.replace(null) + return mock.api.read(key, channel) + } } + await expect(rolloutDesktopRelease(f.plan, scenario === 'decrease' ? 5 : 30, + { transport: api, record: mock.record })).rejects.toThrow() + expect(mock.writes).toHaveLength(scenario === 'unconfirmed' ? 1 : 0) + } finally { await rm(f.root, { recursive: true, force: true }) } + }) + +it('rejects changed retained YAML before reading remote metadata', async () => { + const f = await fixture() + try { + await writeFile(join(f.directory, f.filename), 'version: 9.0.0') + await expect(prepareDesktopRollout(f.directory, f.manifest, f.environment)).rejects.toThrow(/differs from manifest/u) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it('requires initial publication evidence and prepares approval without installer or credential files', async () => { + const f = await fixture() + try { + const config = join(f.root, 'config') + await mkdir(join(config, 'publishing'), { recursive: true }) + await writeFile(join(config, 'publishing/production.json'), JSON.stringify({ + bucket: 'bucket-123', origin: 'https://download.deepseek.com', credentialsFile: join(f.root, 'absent-secret'), + githubApp: { clientId: 'app', privateKeyFile: join(f.root, 'absent-key') }, + })) + const route = { ...f.manifest.source, runnerLabel: 'dsh-runner' } + const env = { RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: config, + DESKTOP_PUBLISH_ROUTE: JSON.stringify(route), DESKTOP_ROLLOUT_PERCENTAGE: '30', DESKTOP_UPLOAD_WEBSITE: 'false' } + await expect(prepareDesktopRolloutCi(env)).rejects.toThrow(/initial COS/u) + const record = await createDesktopPublicationRecord(join(f.root, 'upload-records'), { + repository: 'owner/repo', runId: '42', attempt: '1', + destination: `bucket-123/${f.plan.upload.publicUrl}nightly`, actor: 'operator', + operation: 'publish', reason: 'fixture', stagingPercentage: 10, + }) + await record.record({ type: 'feeds-complete' }) + const context = await prepareDesktopRolloutCi(env) + expect(context.target).toBe(30) + expect((await prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: '100' })).approval).not.toBe(context.approval) + await expect(prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: '' })).rejects.toThrow(/select a percentage/u) + await expect(prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: 'NaN' })).rejects.toThrow(/integer/u) + await expect(prepareDesktopRolloutCi({ ...env, RUNNER_NAME: 'other' })).rejects.toThrow(/original machine/u) + await expect(prepareDesktopRolloutCi({ ...env, DESKTOP_UPLOAD_WEBSITE: 'true' })).rejects.toThrow(/production and/u) + expect((await prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: '', + DESKTOP_UPLOAD_WEBSITE: 'true', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-example' })).website).toBe(true) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it('keeps rollout approval and website recovery separate without tag creation or full package verification', async () => { + const text = await readFile('.github/workflows/desktop-rollout.yml', 'utf8') + const workflow = load(text) as { + name: string + on: { workflow_dispatch: { inputs: Record } } + jobs: Record; needs?: string[] }> + } + expect(workflow.name).toBe('Publish Desktop · Rollout & Website') + expect(workflow.on.workflow_dispatch.inputs.package_run_attempt!.required).toBe(true) + expect(workflow.on.workflow_dispatch.inputs.upload_website!.default).toBe(false) + expect(workflow.on.workflow_dispatch.inputs.rollout_percentage!.default).toBe('') + expect(workflow.jobs.publish!.environment).toContain('desktop-production') + expect(workflow.jobs['upload-website-installer']!.needs).toContain('publish') + expect(workflow.jobs['purge-website-installer']!.needs).toContain('upload-website-installer') + expect(text).not.toMatch(/create-github-app-token|publish-desktop-ci.ts|resolve-retained-desktop-publish-ci.ts/u) + expect(text).toContain('publish-desktop-rollout-ci.ts route') + expect(workflow.jobs.publish!.env!.DESKTOP_UPLOAD_WEBSITE).toBe('${{ inputs.upload_website }}') + expect(text).toContain('steps.website.outputs.rollout-record') +}) + +it.each(['apply', 'changed-approval', 'replaced-feed', 'website'] as const)('checks approval, current feed and audit at the %s entrypoint', async (scenario) => { + const f = await fixture() + try { + const config = join(f.root, 'config') + const credentialsFile = join(f.root, 'credentials.json') + await mkdir(join(config, 'publishing'), { recursive: true }) + await writeFile(credentialsFile, JSON.stringify({ secretId: 'fixture', secretKey: 'fixture' })) + await writeFile(join(config, 'publishing/production.json'), JSON.stringify({ + bucket: 'bucket-123', origin: 'https://download.deepseek.com', credentialsFile, + githubApp: { clientId: 'app', privateKeyFile: join(f.root, 'absent-key') }, + })) + const record = await createDesktopPublicationRecord(join(f.root, 'upload-records'), { + repository: 'owner/repo', runId: '42', attempt: '1', actor: 'operator', + destination: `bucket-123/${f.plan.upload.publicUrl}nightly`, operation: 'publish', reason: 'fixture', + }) + await record.record({ type: 'feeds-complete' }) + const env = { RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: config, + DESKTOP_PUBLISH_ROUTE: JSON.stringify({ ...f.manifest.source, runnerLabel: 'dsh-runner' }), + DESKTOP_ROLLOUT_PERCENTAGE: scenario === 'website' ? '' : '30', DESKTOP_UPLOAD_WEBSITE: scenario === 'website' ? 'true' : 'false', + DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', GITHUB_OUTPUT: join(f.root, 'output'), GITHUB_STEP_SUMMARY: join(f.root, 'summary') } + const context = await prepareDesktopRolloutCi(env) + await publishDesktopRolloutCi('check', env) + expect(await readFile(env.GITHUB_OUTPUT, 'utf8')).toContain(`approval=${context.approval}`) + if (scenario === 'website') { + const summary = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8') + expect(summary).toContain('官网安装包面向所有下载用户,不受自动更新放量比例限制') + expect(summary).toContain('固定下载地址无防降级保护,发布较早版本会替换较新安装包') + } + const mock = transport(dump({ ...(load(f.plan.expected) as object), stagingPercentage: 10 })) + if (scenario === 'replaced-feed') mock.replace(null) + const createTransport = vi.spyOn(cosTransport, 'desktopPublishCos').mockReturnValue(mock.api) + const website = vi.spyOn(websitePublisher, 'publishWebsiteCi').mockImplementation(async (mode) => { + expect(mode).toBe('upload') + expect(mock.reads).toHaveLength(1) + expect(mock.writes).toHaveLength(0) + }) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const approved = { ...env, DESKTOP_ROLLOUT_APPROVAL: scenario === 'changed-approval' ? 'changed' : context.approval } + const execution = publishDesktopRolloutCi(scenario === 'website' ? 'website-upload' : 'apply', approved) + if (scenario === 'changed-approval' || scenario === 'replaced-feed') { + await expect(execution).rejects.toThrow(scenario === 'changed-approval' ? 'approved inputs' : 'feed is missing') + expect(mock.writes).toHaveLength(0) + expect(website).not.toHaveBeenCalled() + if (scenario === 'changed-approval') expect(createTransport).not.toHaveBeenCalled() + } else { + await execution + expect(mock.writes).toHaveLength(scenario === 'apply' ? 1 : 0) + expect(website).toHaveBeenCalledTimes(scenario === 'website' ? 1 : 0) + const output = await readFile(env.GITHUB_OUTPUT, 'utf8') + const key = scenario === 'website' ? 'rollout-record=' : 'record=' + const directory = output.split('\n').find(line => line.startsWith(key))!.slice(key.length) + expect(JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))).toMatchObject({ status: 'complete' }) + expect(await readFile(join(directory, 'events.jsonl'), 'utf8')).toContain(scenario === 'apply' ? 'put-verified' : 'object-present') + expect(await readFile(env.GITHUB_STEP_SUMMARY, 'utf8')).toContain(scenario === 'apply' ? '10% → 30%' : '10% → 10%') + } + } finally { vi.restoreAllMocks(); await rm(f.root, { recursive: true, force: true }) } +}) + +it('routes without package binaries only for the explicit metadata-only reader and keeps reviewer checks', async () => { + const f = await fixture() + try { + const env = { RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: join(f.root, 'config'), + DESKTOP_PACKAGE_SELECTION: JSON.stringify({ runnerName: 'runner', + run: { id: 42, run_attempt: 1, head_sha: f.manifest.source.workflowCommit, path: '.github/workflows/macos-package.yml', + conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } }, + jobs: [{ name: 'package', runner_name: 'runner', conclusion: 'success', labels: ['self-hosted', 'dsh-runner'] }], + routes: { runner: { label: 'dsh-runner', target: 'mac-arm64' } }, + }) } + const api = async (path: string) => path.includes('/environments/') + ? { protection_rules: [{ type: 'required_reviewers', reviewers: [{}] }] } : { sha: f.manifest.source.commit } + await expect(resolveRetainedDesktopPublication(env, api)).rejects.toThrow() + expect(await resolveRetainedDesktopPublication(env, api, readRetainedDesktopManifest)).toMatchObject({ + runId: '42', attempt: '1', runnerName: 'runner', preset: 'production', + }) + await expect(resolveRetainedDesktopPublication(env, async path => path.includes('/environments/') + ? { protection_rules: [] } : api(path), readRetainedDesktopManifest)).rejects.toThrow('requires configured reviewers') + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it('runs the real route CLI without gh and emits the selected route and preset', async () => { + const f = await fixture() + const requests: Array<{ url: string | undefined; token: string | undefined }> = [] + const server = createServer((request, response) => { + requests.push({ url: request.url, token: request.headers.authorization }) + response.writeHead(200, { 'content-type': 'application/json', connection: 'close' }) + response.end(JSON.stringify(request.url?.includes('/environments/') + ? { protection_rules: [{ type: 'required_reviewers', reviewers: [{}] }] } : { sha: f.manifest.source.commit })) + }) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() }) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('expected TCP listener') + const output = join(f.root, 'route-output') + const selection = { runnerName: 'runner', + run: { id: 42, run_attempt: 1, head_sha: f.manifest.source.workflowCommit, path: '.github/workflows/macos-package.yml', + conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } }, + jobs: [{ name: 'package', runner_name: 'runner', conclusion: 'success', labels: ['self-hosted', 'dsh-runner'] }], + routes: { runner: { label: 'dsh-runner', target: 'mac-arm64' } }, + } + const result = await promisify(execFile)(process.execPath, + ['--import', 'tsx/esm', 'apps/desktop/scripts/publish-desktop-rollout-ci.ts', 'route'], { + timeout: 30_000, env: { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path')), PATH: '', + GH_TOKEN: 'fixture-token', GITHUB_API_URL: `http://127.0.0.1:${address.port}`, GITHUB_OUTPUT: output, + RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: join(f.root, 'config'), + DESKTOP_PACKAGE_SELECTION: JSON.stringify(selection), + }, + }) + expect(result.stderr).toBe('') + const lines = (await readFile(output, 'utf8')).trim().split('\n') + expect(lines[1]).toBe('preset=production') + expect(JSON.parse(lines[0]!.slice('route='.length))).toMatchObject({ runId: '42', attempt: '1', target: 'mac-arm64' }) + expect(requests.map(request => request.url)).toEqual([ + `/repos/owner/repo/commits/${f.manifest.source.commit}`, + `/repos/owner/repo/commits/${encodeURIComponent(`refs/tags/${f.manifest.source.sourceRef}`)}`, + '/repos/owner/repo/environments/desktop-production', + ]) + expect(requests.every(request => request.token === 'Bearer fixture-token')).toBe(true) + } finally { + server.closeAllConnections() + if (server.listening) { + await new Promise((resolve, reject) => server.close((error) => { if (error) reject(error); else resolve() })) + } + await rm(f.root, { recursive: true, force: true }) + } +}) + +it.each(['mac-arm64', 'win-x64'] as const)('accepts %s metadata produced by the original full upload planner', async (target) => { + const f = await fixture(target) + try { + const base = `deepseek-harness-${f.manifest.source.version}-${target}` + const extension = target === 'win-x64' ? 'exe' : 'zip' + await writeFile(join(f.directory, `${base}.${extension}`), 'installer') + await writeFile(join(f.directory, `${base}.${extension}.blockmap`), 'blockmap') + if (target !== 'win-x64') await writeFile(join(f.directory, `${base}.dmg`), 'installer') + await writeFile(join(f.directory, desktopBuildRecordFilename(target)), JSON.stringify({ + schemaVersion: 1, target, version: f.manifest.source.version, channel: 'nightly', + environment: 'production', publicUrl: f.plan.upload.publicUrl, commit: f.manifest.source.commit, dirty: false, + })) + const original = await createDesktopUploadPlan(target, { artifactsRoot: f.directory, environment: f.environment, + packagedProductVersion: f.manifest.source.version, stagingPercentage: 10 }) + const feed = original.artifacts.find(artifact => artifact.channelMetadata)! + const mock = transport(feed.contents!) + expect(await rolloutDesktopRelease(f.plan, 30, { transport: mock.api, record: mock.record })) + .toEqual({ before: 10, after: 30 }) + expect(load(mock.writes[0]!)).toEqual({ ...(load(feed.contents!) as object), stagingPercentage: 30 }) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it.each(['missing-yaml', 'wrong-approval', 'not-uploaded', 'wrong-zone', 'not-selected'] as const)( + 'preserves independent website purge recovery for %s', async (scenario) => { + const f = await fixture() + try { + const config = join(f.root, 'config') + const credentialsFile = join(f.root, 'credentials.json') + await mkdir(join(config, 'publishing'), { recursive: true }) + await writeFile(credentialsFile, JSON.stringify({ secretId: 'fixture-id', secretKey: 'fixture-key' })) + await writeFile(join(config, 'publishing/production.json'), JSON.stringify({ + bucket: 'bucket-123', origin: 'https://download.deepseek.com', credentialsFile, + githubApp: { clientId: 'app', privateKeyFile: join(f.root, 'absent-key') }, + })) + await rm(join(f.directory, f.filename)) + const directory = join(f.root, 'upload-records', 'website-99-mac-arm64') + await mkdir(directory, { recursive: true }) + const approval = websitePublisher.websiteApproval(await readRetainedDesktopManifest(join(f.root, 'releases'), '42', '1'), 'bucket-123') + const publicUrl = desktopWebsiteInstallerUrl('mac-arm64') + await writeFile(join(directory, 'state.json'), JSON.stringify({ approval, + upload: scenario === 'not-uploaded' ? 'pending' : 'uploaded', purgeAttempt: '1', + purge: { zoneId: 'zone-fixture', publicUrl, submittedAt: '2026-11-04T00:00:00.000Z', + jobId: 'existing-job', status: 'processing' }, + })) + const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockRejectedValue(new Error('unexpected purge submission')) + const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockResolvedValue({ + TotalCount: 1, Tasks: [{ JobId: 'existing-job', Target: publicUrl, Type: 'purge_url', Status: 'success' }], + }) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const execution = publishDesktopRolloutCi('website-purge', { + RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', GITHUB_RUN_ID: '99', GITHUB_RUN_ATTEMPT: '2', + DSH_PACKAGING_CONFIG_DIR: config, DESKTOP_PUBLISH_ROUTE: JSON.stringify({ ...f.manifest.source, runnerLabel: 'dsh-runner' }), + DESKTOP_UPLOAD_WEBSITE: scenario === 'not-selected' ? 'false' : 'true', + DESKTOP_WEBSITE_APPROVAL: scenario === 'wrong-approval' ? 'changed' : approval, + DOWNLOAD_PROD_EO_ZONE_ID: scenario === 'wrong-zone' ? 'zone-changed' : 'zone-fixture', + }) + if (scenario === 'missing-yaml') { + await execution + expect(query).toHaveBeenCalledOnce() + expect(JSON.parse(await readFile(join(directory, 'state.json'), 'utf8'))).toMatchObject({ purge: { status: 'success' } }) + } else { + await expect(execution).rejects.toThrow({ + 'wrong-approval': 'approved build or destination changed', 'not-uploaded': 'upload completion is not confirmed', + 'wrong-zone': 'saved destination differs', 'not-selected': 'website publication was not selected', + }[scenario]) + expect(query).not.toHaveBeenCalled() + } + expect(submit).not.toHaveBeenCalled() + } finally { vi.restoreAllMocks(); await rm(f.root, { recursive: true, force: true }) } + }) + +it('executes the master guard and keeps all rollout preflights ahead of dependency installation', async () => { + const workflow = load(await readFile('.github/workflows/desktop-rollout.yml', 'utf8')) as { + jobs: Record }> }> + } + const route = workflow.jobs.route!.steps + const command = route[0]!.run! + for (const ref of ['refs/heads/master', 'refs/heads/stale-feature']) { + const result = spawnSync(process.execPath, ['-e', command.slice('node -e "'.length, -1)], { + env: { ...process.env, GITHUB_REF: ref }, encoding: 'utf8', timeout: 30_000, + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(ref === 'refs/heads/master' ? 0 : 1) + } + const preflight = route.findIndex(step => step.id === 'preflight') + expect(preflight).toBeGreaterThan(0) + expect(preflight).toBeLessThan(route.findIndex(step => step.uses?.startsWith('pnpm/'))) + expect(route.find(step => step.id === 'route')?.env?.DESKTOP_PACKAGE_ATTEMPT).toBe('${{ steps.preflight.outputs.attempt }}') + for (const [name, job] of Object.entries(workflow.jobs)) { + if (name === 'route') continue + const local = job.steps.findIndex(step => step.run?.includes('preflight-desktop-publish-local.mjs')) + expect(local).toBeGreaterThanOrEqual(0) + expect(local).toBeLessThan(job.steps.findIndex(step => step.run === 'pnpm install --frozen-lockfile')) + } +}) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index 18f3fa18fa..0d9e90f00d 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -178,13 +178,33 @@ describe('desktop upload plan', () => { }) it.each(['test', 'production'] as const)('isolates stable-channel packages from nightly in %s', async (environment) => { - const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly')) - const stable = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'stable')) + const nightly = await createDesktopUploadPlan('win-x64', { ...await fixture('win-x64', '1.2.3', environment, 'nightly'), stagingPercentage: 10 }) + const stable = await createDesktopUploadPlan('win-x64', { ...await fixture('win-x64', '1.2.3', environment, 'stable'), stagingPercentage: 10 }) expect(stable.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['stable.yml']) expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) expect(stable.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) - const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'stable') - await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/stable requires a stable version/u) + for (const plan of [nightly, stable]) { + expect(plan.artifacts.find(file => file.channelMetadata)!.contents).toContain('stagingPercentage: 10') + } + }) + + it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('publishes manually selected prereleases on stable for %s', async (target) => { + const summaries: { environment: string; version: string; keys: string[] }[] = [] + for (const environment of ['test', 'production'] as const) { + for (const version of ['1.2.3-alpha.1', '1.2.3-rc.1']) { + const plan = await createDesktopUploadPlan(target, await fixture(target, version, environment, 'stable')) + expect(plan.version).toBe(version) + const feed = plan.artifacts.find(file => file.channelMetadata)! + expect(feed.filename).toBe(target === 'win-x64' ? 'stable.yml' : 'stable-mac.yml') + const payload = plan.artifacts.find(file => file.filename.endsWith(target === 'win-x64' ? '.exe' : '.zip'))! + expect(payload.key).toContain(`/bin/${target}/stable/`) + expect(load(feed.contents!)).toMatchObject({ version, files: [{ + url: `${environment === 'test' ? TEST_ORIGIN : 'https://download.deepseek.com'}/${payload.key}`, + }] }) + summaries.push({ environment, version, keys: plan.artifacts.map(file => file.key) }) + } + } + await expect(`${JSON.stringify(summaries, null, 2)}\n`).toMatchFileSnapshot(`./expected/stable-prerelease-${target}.json`) }) it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { diff --git a/apps/desktop/tests/expected/publication-mac-arm64.md b/apps/desktop/tests/expected/publication-mac-arm64.md index 7b376d9d65..f975154887 100644 --- a/apps/desktop/tests/expected/publication-mac-arm64.md +++ b/apps/desktop/tests/expected/publication-mac-arm64.md @@ -8,6 +8,7 @@ | 发布环境 | test | | 版本号 | 1.0.0 | | 升级通道 | nightly | +| 更新灰度比例 | 全量(未设置) | | 平台 | mac-arm64 | | 测试 release ID | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 | | 下载地址预览 | https://example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/mac-arm64/deepseek-harness-1.0.0-mac-arm64.dmg
发布后的目标地址,审批前不保证可下载。 | diff --git a/apps/desktop/tests/expected/publication-production-website.md b/apps/desktop/tests/expected/publication-production-website.md index 464650c653..4ba276895c 100644 --- a/apps/desktop/tests/expected/publication-production-website.md +++ b/apps/desktop/tests/expected/publication-production-website.md @@ -8,6 +8,7 @@ | 发布环境 | production | | 版本号 | 1.0.0 | | 升级通道 | stable | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | | 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | diff --git a/apps/desktop/tests/expected/publication-production.md b/apps/desktop/tests/expected/publication-production.md index a3b93ccb87..716e499d5e 100644 --- a/apps/desktop/tests/expected/publication-production.md +++ b/apps/desktop/tests/expected/publication-production.md @@ -8,6 +8,7 @@ | 发布环境 | production | | 版本号 | 1.0.0 | | 升级通道 | stable | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | | 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | diff --git a/apps/desktop/tests/expected/publication-win-x64.md b/apps/desktop/tests/expected/publication-win-x64.md index aa7569c0a2..65684cf866 100644 --- a/apps/desktop/tests/expected/publication-win-x64.md +++ b/apps/desktop/tests/expected/publication-win-x64.md @@ -8,6 +8,7 @@ | 发布环境 | test | | 版本号 | 1.0.0 | | 升级通道 | nightly | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 测试 release ID | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 | | 下载地址预览 | https://example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/win-x64/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | diff --git a/apps/desktop/tests/expected/stable-prerelease-mac-arm64.json b/apps/desktop/tests/expected/stable-prerelease-mac-arm64.json new file mode 100644 index 0000000000..97afcbe70e --- /dev/null +++ b/apps/desktop/tests/expected/stable-prerelease-mac-arm64.json @@ -0,0 +1,42 @@ +[ + { + "environment": "test", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/stable-mac.yml" + ] + }, + { + "environment": "test", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.dmg", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip.blockmap", + "dsh-desk/feeds/mac-arm64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.dmg", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip.blockmap", + "dsh-desk/feeds/mac-arm64/stable-mac.yml" + ] + } +] diff --git a/apps/desktop/tests/expected/stable-prerelease-mac-x64.json b/apps/desktop/tests/expected/stable-prerelease-mac-x64.json new file mode 100644 index 0000000000..45e103bb57 --- /dev/null +++ b/apps/desktop/tests/expected/stable-prerelease-mac-x64.json @@ -0,0 +1,42 @@ +[ + { + "environment": "test", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/stable-mac.yml" + ] + }, + { + "environment": "test", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.dmg", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip.blockmap", + "dsh-desk/feeds/mac-x64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.dmg", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip.blockmap", + "dsh-desk/feeds/mac-x64/stable-mac.yml" + ] + } +] diff --git a/apps/desktop/tests/expected/stable-prerelease-win-x64.json b/apps/desktop/tests/expected/stable-prerelease-win-x64.json new file mode 100644 index 0000000000..f1174feee9 --- /dev/null +++ b/apps/desktop/tests/expected/stable-prerelease-win-x64.json @@ -0,0 +1,38 @@ +[ + { + "environment": "test", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/stable.yml" + ] + }, + { + "environment": "test", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/stable.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe", + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe.blockmap", + "dsh-desk/feeds/win-x64/stable.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe", + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe.blockmap", + "dsh-desk/feeds/win-x64/stable.yml" + ] + } +] diff --git a/apps/desktop/tests/harness.ts b/apps/desktop/tests/harness.ts new file mode 100644 index 0000000000..9fc805558c --- /dev/null +++ b/apps/desktop/tests/harness.ts @@ -0,0 +1,54 @@ +/** Synthetic Desktop project for built-Host e2e tests; callers own `root` and restore stubbed variables. */ + +import { mkdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { vi } from 'vitest' +import { DesktopProjectManager } from '../src/project-manager.ts' +import { resolveDesktopPaths, type DesktopPaths } from '../src/paths.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { prepareDevelopmentProject } from '../scripts/development-project.ts' + +/** Repository root of this checkout. */ +export const repository = fileURLToPath(new URL('../../../', import.meta.url)) +/** Built Host entry that these tests require. */ +export const builtHost = join(repository, 'apps/desktop-host/lib/index.js') + +function version(path: string): string { + return (JSON.parse(readFileSync(path, 'utf8')) as { version: string }).version +} + +/** + * Stub a credential-free Desktop launch environment and apply a synthetic release under `root`. + * @param root - Scratch directory owned by the caller. + * @returns Harness home, synthetic project directory, and Desktop paths under that home. + */ +export async function prepareDesktopHome(root: string): Promise<{ home: string; project: string; paths: DesktopPaths }> { + for (const name of Object.keys(process.env)) { + if (/KEY|TOKEN|SECRET|PASSWORD/u.test(name)) vi.stubEnv(name, undefined) + } + // The production shell supplies the client version that Desktop-only profile rows require. + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') + const home = join(root, 'home') + mkdirSync(home) + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('DSH_TELEMETRY_MODE', 'DISABLED') + const project = prepareDevelopmentProject({ + projectDir: join(root, 'project'), + cliDir: join(repository, 'apps/cli'), + hostDir: join(repository, 'apps/desktop-host'), + dependencyDir: join(repository, 'node_modules/.pnpm/node_modules'), + release: { + schemaVersion: 1, + version: version(join(repository, 'apps/desktop/package.json')), + pnpmVersion: version(join(repository, 'apps/desktop/node_modules/pnpm/package.json')), + nodeVersion: process.versions.node, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + }, + // The synthetic project is not packaged, so nothing compares its descriptor target with this host. + target: 'mac-x64', + }) + const paths = resolveDesktopPaths(home) + await new DesktopProjectManager(paths, { dsh: project }).applyRelease() + return { home, project, paths } +} diff --git a/apps/desktop/tests/installed-docs-asar.built.e2e.ts b/apps/desktop/tests/installed-docs-asar.built.e2e.ts new file mode 100644 index 0000000000..3d684892b9 --- /dev/null +++ b/apps/desktop/tests/installed-docs-asar.built.e2e.ts @@ -0,0 +1,93 @@ +/** A Windows model shell must read installed documentation inside the Desktop archive through Electron's Node mode. */ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { finished } from 'node:stream/promises' +import type { WriteStream } from 'node:fs' +import { expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const builderRequire = createRequire(require.resolve('app-builder-lib/package.json')) +const { createPackageWithOptions } = builderRequire('@electron/asar') as { + createPackageWithOptions: (source: string, destination: string, options: object) => Promise +} + +/** Reads a bounded line range below `DSH_INSTALL_DIR`; a missing file prints its code and exits 1. */ +const READ_SCRIPT = ` +const fs = require("node:fs"), path = require("node:path") +const [target, first = "1", count = "200"] = process.argv.slice(1) +const file = path.resolve(process.env.DSH_INSTALL_DIR, target) +try { + const lines = fs.readFileSync(file, "utf8").split("\\n") + const start = Math.max(1, Number(first)), end = Math.min(lines.length, start + Number(count) - 1) + for (let n = start; n <= end; n++) console.log(\`\${n}\\t\${lines[n - 1].slice(0, 2000)}\`) + if (end < lines.length) console.log(\`[\${lines.length - end} more lines]\`) +} catch (error) { console.error(\`\${error.code ?? "ERROR"}: \${file}\`); process.exitCode = 1 } +` + +/** + * The Electron executable is a GUI-subsystem program: PowerShell's call operator does not wait for it, so + * captured output arrives empty and the exit code is not its own. The Windows form starts it as a .NET + * process with redirected streams and waits for its exit code; the console encoding only lets this test + * decode what the probe relays. + */ +const POWERSHELL_PROBE = `param([string]$Target, [string]$First, [string]$Count) +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$script = @' +${READ_SCRIPT} +'@ +$start = [System.Diagnostics.ProcessStartInfo]::new($env:DSH_NODE_EXECUTABLE) +foreach ($argument in @('-e', $script, '--', $Target, $First, $Count)) { $start.ArgumentList.Add($argument) } +$start.Environment['ELECTRON_RUN_AS_NODE'] = '1' +$start.UseShellExecute = $false +$start.RedirectStandardOutput = $true +$start.RedirectStandardError = $true +$start.StandardOutputEncoding = [System.Text.Encoding]::UTF8 +$start.StandardErrorEncoding = [System.Text.Encoding]::UTF8 +$process = [System.Diagnostics.Process]::Start($start) +$stderr = $process.StandardError.ReadToEndAsync() +$stdout = $process.StandardOutput.ReadToEnd() +$process.WaitForExit() +[Console]::Out.Write($stdout) +[Console]::Error.Write($stderr.Result) +exit $process.ExitCode +` + +// macOS packaged-application coverage is manual; the POSIX form needs no output capture workaround. +it.skipIf(process.platform !== 'win32' || process.env.DSH_EXAMPLE_MODE !== 'lib')('reads archived documentation through the PowerShell form', { retry: 0 }, async () => { + const electron = require('electron') as string + const root = await mkdtemp(join(tmpdir(), 'dsh 文档 asar-')) + try { + const source = join(root, 'source') + await mkdir(join(source, 'docs', 'upgrade-guide'), { recursive: true }) + await writeFile(join(source, 'package.json'), '{"name":"@deepseek-ai/dsh"}\n') + await writeFile(join(source, 'docs', 'upgrade-guide', 'README.md'), '# 升级指南\n\n第二行\n第三行\n') + const archive = join(root, '安装 目录', 'app.asar') + await mkdir(join(archive, '..')) + await finished(await createPackageWithOptions(source, archive, {})) + const probe = join(root, 'probe.ps1') + await writeFile(probe, POWERSHELL_PROBE) + const run = (target: string, first: string, count: string) => { + const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-File', probe, target, first, count], { + env: { ...process.env, DSH_NODE_EXECUTABLE: electron, DSH_INSTALL_DIR: archive }, + encoding: 'utf8', timeout: 60_000, windowsHide: true, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + return { status: result.status, stdout: result.stdout.replaceAll('\r\n', '\n'), stderr: result.stderr } + } + + const read = run('docs/upgrade-guide/README.md', '1', '2') + expect(read.status, read.stderr).toBe(0) + expect(read.stdout).toBe('1\t# 升级指南\n2\t\n[3 more lines]\n') + + const missing = run('docs/upgrade-guide/missing.md', '1', '2') + expect(missing.status).toBe(1) + expect(missing.stdout).toBe('') + expect(missing.stderr).toContain(`ENOENT: ${join(archive, 'docs', 'upgrade-guide', 'missing.md')}`) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index b5ac451827..56bba443e3 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -117,9 +117,10 @@ it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpe mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true }) const node = join(path, 'dependencies/node/bin/node') const addon = join(path, 'addon.node') - const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/libreoffice-kit')) - for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true }) - for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) + const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/dsoffice')) + const library = join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/lib/libmergedlo.dylib') + for (const file of [...helpers, library]) mkdirSync(join(file, '..'), { recursive: true }) + for (const file of [node, addon, library, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) await signMacOSRuntime(path, 'com.example.app', identity, arch) const nodePlist = join(import.meta.dirname, '../scripts', arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist') expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, nodePlist) @@ -131,4 +132,19 @@ it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpe join(import.meta.dirname, '../scripts/jit-entitlements.plist')) } expect(signMacOSRuntimeCode).toHaveBeenCalledWith(addon, expect.any(String), identity, undefined) + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(library, expect.any(String), identity, undefined) +}) + +it('grants JIT to every Mach-O executable directly under a darwin Kit engine bin directory regardless of its name', async () => { + const path = root() + const helpers = ['dsoffice', 'renamed-helper'].map(name => join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/bin', name)) + const nested = join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/bin/nested/tool') + for (const file of [...helpers, nested]) { + mkdirSync(join(file, '..'), { recursive: true }) + writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) + } + await signMacOSRuntime(path, 'com.example.app', identity, 'arm64') + const jit = join(import.meta.dirname, '../scripts/jit-entitlements.plist') + for (const file of helpers) expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity, jit) + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(nested, expect.any(String), identity, undefined) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index cf3fd803c0..69ec6c9af9 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -725,6 +725,20 @@ describe('desktop main startup', () => { expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'channel' }) }) + it('starts the Host without resolving Kit or writing a diagnostic file', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-main-office-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + writeFileSync(join(root, 'app.asar'), 'archive fixture') + harness.app.isPackaged = false + vi.stubEnv('DSH_DESKTOP_DSH_DIR', join(root, 'app.asar', 'dsh')) + const host = await readyForUpdate() + await vi.waitFor(() => { expect(harness.analytics.mock.calls.some(([event]) => (event as { eventName: string }).eventName === 'desktop_app_launch')).toBe(true) }) + expect(host.primaryRuntime).toBe('test-primary-runtime') + expect(existsSync(harness.app.getPath('logs'))).toBe(false) + expect(writeCrashReport).not.toHaveBeenCalled() + expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed })) + }) + it('starts the Host with authentication popups disabled when local settings are invalid', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') @@ -1328,7 +1342,7 @@ describe('desktop main startup', () => { await edit }) - it.each(['darwin', 'linux'] as const)('adds the product File menu and standard window commands only on macOS (%s)', async (platform) => { + it.each(['darwin', 'linux'] as const)('declares the platform application menus, with the product File, Edit, and Window menus on macOS (%s)', async (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) await import('../src/main.ts') await harness.preparing.promise @@ -1341,6 +1355,17 @@ describe('desktop main startup', () => { expect(template.map(describeItem)).toEqual(platform === 'darwin' ? ['Desktop test', en.fileMenu, 'editMenu', 'windowMenu'] : ['Application', 'editMenu']) + const describeEdit = (items: MenuItemConstructorOptions[]): unknown[] => items.map(item => Array.isArray(item.submenu) + ? [describeItem(item), describeEdit(item.submenu)] + : [describeItem(item), item.accelerator, item.visible, item.acceleratorWorksWhenHidden].filter(value => value !== undefined)) + const edit = template.find(item => item.role === 'editMenu')!.submenu as MenuItemConstructorOptions[] | undefined + expect(edit === undefined ? undefined : describeEdit(edit)).toEqual(platform === 'darwin' + ? [['undo'], ['redo'], ['separator'], ['cut'], ['copy'], ['paste'], + ['pasteAndMatchStyle', 'Shift+Command+V'], ['pasteAndMatchStyle', 'Option+Shift+Command+V', false, true], + ['delete'], ['selectAll'], ['separator'], + [en.substitutionsMenu, [['showSubstitutions'], ['separator'], ['toggleSmartQuotes'], ['toggleSmartDashes'], ['toggleTextReplacement']]], + [en.speechMenu, [['startSpeaking'], ['stopSpeaking']]]] + : undefined) const application = template[0]!.submenu as MenuItemConstructorOptions[] expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin' ? ['about', 'separator', en.checkUpdatesMenu, en.cliCommandMenu, 'separator', 'toggleDevTools', 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] diff --git a/apps/desktop/tests/office-resources.e2e.ts b/apps/desktop/tests/office-resources.e2e.ts new file mode 100644 index 0000000000..34b9d86e7a --- /dev/null +++ b/apps/desktop/tests/office-resources.e2e.ts @@ -0,0 +1,87 @@ +/** Built Desktop Host startup with incomplete or unusable Office resources; run after the repository build. */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DesktopHostProcess } from '../src/host-process.ts' +import { builtHost, prepareDesktopHome } from './harness.ts' + +afterEach(() => { vi.unstubAllEnvs() }) + +const SKILL_PROBE = 'desktop-office-skill-probe' +const OFFICE_SKILLS = ['office', 'office-docx', 'office-pptx', 'office-xlsx'] + +/** Add a test-only profile bundle whose route reports the Host registry's Office skills through the real Loader. */ +function installSkillProbe(profile: string): void { + const plugin = join(profile, 'node_modules', SKILL_PROBE) + mkdirSync(plugin, { recursive: true }) + writeFileSync(join(plugin, 'package.json'), JSON.stringify({ + name: SKILL_PROBE, version: '1.0.0', type: 'module', exports: './index.js', dsh: { bundle: { patch: './bundle.yml' } }, + })) + writeFileSync(join(plugin, 'index.js'), ` +export function apply(ctx) { + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/${SKILL_PROBE}', async handler(_request, response) { + try { + const names = (await ctx.skills.list()).map(skill => skill.name).filter(name => name.startsWith('office')) + const content = {} + for (const name of ['office', 'office-docx']) content[name] = (await ctx.skills.get(name))?.content ?? null + response.end(JSON.stringify({ names, content, officeKit: ctx.get('officeKit') !== undefined, tools: ctx.tools.schemas().map(tool => tool.name).filter(name => name.startsWith('office_')) })) + } catch (error) { + response.statusCode = 500 + response.end(String(error?.stack ?? error)) + } + } })) +} +`) + writeFileSync(join(plugin, 'bundle.yml'), `- insert:\n - id: ${SKILL_PROBE}\n name: ${SKILL_PROBE}\n inject: [webServer, skills, tools]\n`) + const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh: { profile: { bundles: string[] } } + } + manifest.dependencies = { ...manifest.dependencies, [SKILL_PROBE]: `file:${plugin}` } + manifest.dsh.profile.bundles.push(SKILL_PROBE) + writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) +} + +describe.skipIf(!existsSync(builtHost))('built Desktop Host Office resources', () => { + it.each([ + ['an empty runtime directory', 'empty-runtime'], + ['an unusable primary runtime path', 'relative-source'], + ] as const)('starts the Host with %s and the packaged Office skills', async (_label, mode) => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-office-')) + let host: DesktopHostProcess | undefined + const failures: Error[] = [] + try { + const { project, paths } = await prepareDesktopHome(root) + const runtime = join(root, 'runtime') + mkdirSync(runtime) + writeFileSync(join(paths.profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') + installSkillProbe(paths.profile) + const primaryRuntime = mode === 'relative-source' ? 'runtime/primary-runtime' : join(runtime, 'primary-runtime') + host = new DesktopHostProcess(process.execPath, project, paths.profile, undefined, + process.env, (error) => { failures.push(error) }, primaryRuntime) + const { url } = await host.start() + // The authenticated URL exchanges its token for a session cookie by redirecting. + const response = await fetch(url, { redirect: 'manual' }) + await response.body?.cancel() + expect(response.status).toBe(303) + const cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ') + const probe = await fetch(new URL(`/${SKILL_PROBE}`, url), { headers: { cookie } }) + const body = await probe.text() + expect(probe.status, body).toBe(200) + const skills = JSON.parse(body) as { names: string[]; content: Record; officeKit: boolean; tools: string[] } + expect(skills.officeKit).toBe(true) + // Agent presets, not the Host root, mount the Office tools. + expect(skills.tools).toEqual([]) + // skill-office reads the skills from its packaged assets, independent of the primary runtime directory. + expect(skills.names.toSorted()).toEqual(OFFICE_SKILLS) + expect(skills.content['office-docx']).toEqual(expect.any(String)) + expect(skills.content.office).toContain('`office_render`') + expect(failures).toEqual([]) + } finally { + await host?.stop() + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/desktop/tests/pack-local-packages.spec.ts b/apps/desktop/tests/pack-local-packages.spec.ts index 14d55a0a8b..4faea2d9e2 100644 --- a/apps/desktop/tests/pack-local-packages.spec.ts +++ b/apps/desktop/tests/pack-local-packages.spec.ts @@ -103,10 +103,17 @@ it('rejects local client artifacts in the release packing command', async () => it('validates local tarball contents before reporting success', async () => { const root = fixture() - write(root, 'packages/client/probe/src/index.ts', 'export const source = true\n') + write(root, 'pnpm-workspace.yaml', 'packages:\n - packages/*/*\n - apps/*\n') + write(root, 'apps/cli/package.json', JSON.stringify({ name: '@deepseek-ai/dsh', version: '1.2.3', files: ['lib', 'src'] })) + write(root, 'apps/cli/lib/index.js', 'export const cli = true\n') + write(root, 'apps/cli/src/bin.ts', 'export const bin = true\n') + for (const name of ['subagent-claude-code', 'subagent-codex']) { + write(root, `packages/subagent/${name}/package.json`, JSON.stringify({ name: `@deepseek-ai/dsh-${name}`, version: '1.2.3', files: ['lib'] })) + write(root, `packages/subagent/${name}/lib/index.js`, 'export const bundle = true\n') + } const result = await pack(root) expect(result.code).not.toBe(0) - expect(result.output).toContain('publishes source file package/src/index.ts') + expect(result.output).toContain('@deepseek-ai/dsh packed no documentation') }, testTimeout) it('rejects an invalid local pack concurrency before replacing packages', async () => { diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts index f445682467..7f8cc1a8fc 100644 --- a/apps/desktop/tests/prepare-windows-ci.spec.ts +++ b/apps/desktop/tests/prepare-windows-ci.spec.ts @@ -14,7 +14,7 @@ describe('manual Windows CI version', () => { }) it.each(['', 'bad', '0.3.0'])('reports checkout-specific guidance for %s', (version) => { expect(() => windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)) - .toThrow(/auto, 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u) + .toThrow(/version_mode=build-number, or use 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u) }) it.each([{ GITHUB_RUN_NUMBER: '0' }, { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }])('rejects invalid run identity %j', (override) => { expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u) @@ -25,7 +25,7 @@ describe('manual Windows CI version', () => { it('resolves the selected version policy independently of the update channel', () => { expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'production' }, '1.2.3', now)).toBe('1.2.3') expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'test' }, '1.2.3', now)).toBe('1.2.3-test.20261009.42.2') - expect(windowsCiVersion({ PACKAGE_VERSION_MODE: 'product' }, '1.2.3-alpha.1', now)).toBe('1.2.3-alpha.1') + expect(windowsCiVersion({ PACKAGE_VERSION_MODE: 'follow-dsh' }, '1.2.3-alpha.1', now)).toBe('1.2.3-alpha.1') expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: 'auto' }, '1.2.3', now)).toThrow(/explicit version/u) expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'invalid' }, '1.2.3', now)).toThrow(/version mode/u) }) diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts index cc26e3a2d6..fdddcdf451 100644 --- a/apps/desktop/tests/retain-desktop-ci.spec.ts +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -1,6 +1,8 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { unzipSync } from 'fflate' import { afterEach, expect, it } from 'vitest' import { desktopReleasesRoot, retainDesktopCi, resolveDesktopRetentionRunner } from '../scripts/retain-desktop-ci.ts' import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts' @@ -10,9 +12,7 @@ import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts' const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) -it.each([ - ['mac-arm64', 'notarized'], ['mac-x64', 'notarized'], ['mac-x64', 'unnotarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'], -] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => { +async function retainedFixture(target: 'win-x64' | 'mac-x64' | 'mac-arm64', qualification: 'unsigned' | 'signed' | 'notarized' | 'unnotarized') { const root = await mkdtemp(join(tmpdir(), 'retain-ci-')) roots.push(root) const appRoot = join(root, 'checkout') @@ -29,7 +29,7 @@ it.each([ : [`deepseek-harness-1.0.0-${target}.dmg`, `deepseek-harness-1.0.0-${target}.zip`, `deepseek-harness-1.0.0-${target}.zip.blockmap`, `${target}-release.json`, 'nightly-mac.yml'] await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture'))) const env = { - DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', + DSH_DESKTOP_RELEASES_DIR: '', DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'mac-arm64' ? 'arm64' : 'x64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_STEP_SUMMARY: join(root, 'summary.md'), @@ -39,6 +39,66 @@ it.each([ const directory = await retainDesktopCi(env, appRoot) const archiveEnv = { ...env, GITHUB_RUN_ATTEMPT: '3', DESKTOP_RETAINED_DIRECTORY: directory, DESKTOP_PACKAGE_ATTEMPT: '2', DESKTOP_PACKAGE_RUNNER: env.RUNNER_NAME } + return { root, debug, names, env, directory, archiveEnv } +} + +const archiveScript = join(import.meta.dirname, '../scripts/archive-desktop-ci.ts') + +it.runIf(process.platform === 'darwin' || process.platform === 'win32')('stores installer bytes with local DOS and absolute Unix modification times', async () => { + const { root, directory, archiveEnv, names } = await retainedFixture('win-x64', 'signed') + const timestamp = Date.UTC(2026, 9, 23, 8, 2, 43) / 1000 + await utimes(join(directory, 'manifest.json'), timestamp, timestamp) + const output = join(root, 'archive-output') + execFileSync(process.execPath, ['--import', 'tsx', archiveScript], { + env: { ...process.env, ...archiveEnv, TZ: 'TST-3', DESKTOP_ARCHIVE_NAME: 'fixture.zip', GITHUB_OUTPUT: output, RUNNER_TEMP: root }, + }) + const path = (await readFile(output, 'utf8')).trim().slice('path='.length) + const bytes = await readFile(path) + const entries = unzipSync(bytes) + expect(Object.keys(entries)).toEqual(['manifest.json', ...names]) + for (const [name, content] of Object.entries(entries)) expect(Buffer.from(content)).toEqual(await readFile(join(directory, name))) + // UTC+3 is 11:02:43 on every test host. + const directoryOffset = bytes.readUInt32LE(bytes.length - 22 + 16) + expect(bytes.readUInt32LE(directoryOffset)).toBe(0x02014b50) + const nameLength = bytes.readUInt16LE(directoryOffset + 28) + expect(bytes.subarray(directoryOffset + 46, directoryOffset + 46 + nameLength).toString('utf8')).toBe('manifest.json') + expect(bytes.readUInt16LE(directoryOffset + 10)).toBe(0) + const dosTime = bytes.readUInt16LE(directoryOffset + 12) + expect([dosTime >>> 11, (dosTime >>> 5) & 63, (dosTime & 31) * 2]).toEqual([11, 2, 42]) + const start = directoryOffset + 46 + nameLength + const end = start + bytes.readUInt16LE(directoryOffset + 30) + let unixTime: number | undefined + for (let offset = start; offset < end; offset += 4 + bytes.readUInt16LE(offset + 2)) { + if (bytes.readUInt16LE(offset) === 0x5455 && (bytes[offset + 4]! & 1)) unixTime = bytes.readUInt32LE(offset + 5) + } + expect(unixTime).toBe(timestamp) +}) + +it.runIf(process.platform === 'darwin' || process.platform === 'win32')('reports a ZIP creation failure without emitting an upload path', async () => { + const { root, archiveEnv } = await retainedFixture('win-x64', 'signed') + const output = join(root, 'archive-output') + const result = spawnSync(process.execPath, ['--import', 'tsx', archiveScript], { encoding: 'utf8', + env: { ...process.env, ...archiveEnv, RUNNER_TEMP: root, GITHUB_OUTPUT: output, DESKTOP_ARCHIVE_NAME: 'a'.repeat(256) + '.zip' } }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('desktop archival: ZIP creation failed') + await expect(readFile(output)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each(['../escape.zip', '-option.zip', 'missing-extension', 'missing-output'])('rejects invalid archive settings %s without emitting an upload path', async (name) => { + const { root, archiveEnv } = await retainedFixture('win-x64', 'signed') + const output = join(root, 'archive-output') + const result = spawnSync(process.execPath, ['--import', 'tsx', archiveScript], { encoding: 'utf8', + env: { ...process.env, ...archiveEnv, RUNNER_TEMP: root, GITHUB_OUTPUT: name === 'missing-output' ? '' : output, + DESKTOP_ARCHIVE_NAME: name === 'missing-output' ? 'fixture.zip' : name, RUNNER_NAME: 'wrong-machine' } }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('desktop archival: invalid ZIP output settings') + await expect(readFile(output)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([ + ['mac-arm64', 'notarized'], ['mac-x64', 'notarized'], ['mac-x64', 'unnotarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'], +] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => { + const { debug, names, env, archiveEnv } = await retainedFixture(target, qualification) await verifyDesktopArchive(archiveEnv) await expect(verifyDesktopArchive({ ...archiveEnv, RUNNER_NAME: 'other' })).rejects.toThrow('wrong original machine') await expect(verifyDesktopArchive({ ...archiveEnv, DESKTOP_PACKAGE_ATTEMPT: '3' })).rejects.toThrow('original build') diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index ed478b42f6..08007542fe 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -30,7 +30,7 @@ it('omits development artifacts while preserving executable modules, assets and '@mixmark-io/domino/.yarn/plugins/@yarnpkg/plugin-version.cjs', '@mixmark-io/domino/.yarn/versions/release.yml', '@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data', - '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit', + '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/dsoffice', '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', ] const retained = [ @@ -50,9 +50,15 @@ it('omits development artifacts while preserving executable modules, assets and '@mixmark-io/domino/LICENSE', '@mixmark-io/domino/package.json', 'example/.yarn/runtime.cjs', '@mixmark-io/domino/.yarn-data.json', '@img/sharp-win32-x64/lib/libvips-42.dll', - '@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe', + '@deepseek-ai/libreoffice-kit-win32-x64/bin/dsoffice.exe', '@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json', '@deepseek-ai/libreoffice-kit-win32-x64/licenses/MPL-2.0.txt', + '@deepseek-ai/dsh/lib/index.js', '@deepseek-ai/dsh/lib/bin.js', + '@deepseek-ai/dsh-session/lib/index.js', + '@deepseek-ai/dsh/src/bin.ts', '@deepseek-ai/dsh/docs/architecture.md', + '@deepseek-ai/dsh/docs/upgrade-guide/v0.2.0/example/guide.md', + '@deepseek-ai/dsh-session/src/index.ts', + '@deepseek-ai/dsh-client-ui-chat/src/client/Page.tsx', ] try { const runtime = runtimeFixture(source) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 75521799f4..b65f825bbc 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -244,18 +244,28 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), }) -it('keeps a stable-version nightly installation on nightly and rejects prereleases on stable', async () => { - const nightly = fixture('nightly', '1.0.0') - expect(nightly.updater.channel).toBe('nightly') - expect(nightly.updater.allowPrerelease).toBe(true) - expect((await nightly.coordinator.check()).phase).toBe('available') - expect(nightly.updater.channel).toBe('nightly') - const stable = fixture('stable', '1.0.0') - expect(stable.updater.channel).toBe('stable') - expect(stable.updater.allowPrerelease).toBe(false) - expect(stable.updater.allowDowngrade).toBe(false) - expect((await stable.coordinator.check()).phase).toBe('error') - expect(stable.downloadUpdate).not.toHaveBeenCalled() +it.each(['nightly', 'stable'] as const)('accepts newer prereleases on the selected %s channel', async (channel) => { + const f = fixture(channel, '1.0.0') + expect(f.updater.channel).toBe(channel) + expect(f.updater.allowPrerelease).toBe(true) + expect(f.updater.allowDowngrade).toBe(false) + for (const version of ['1.1.0-alpha.1', '1.1.0-rc.2', '1.1.0']) { + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version } }) + expect(await f.coordinator.check()).toMatchObject({ phase: 'available', version }) + expect(f.updater.channel).toBe(channel) + } +}) + +it.each(['nightly', 'stable'] as const)('rejects older, equal and malformed versions on %s', async (channel) => { + for (const version of ['0.9.0', '1.0.0-rc.1', '1.0.0', 'invalid']) { + const f = fixture(channel, '1.0.0') + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version } }) + expect((await f.coordinator.check()).phase).toBe(version === 'invalid' ? 'error' : 'idle') + await expect(f.coordinator.download(version)).rejects.toThrow(/no checked update/u) + await expect(f.coordinator.install(version)).rejects.toThrow(/confirmed target is not ready/u) + expect(f.downloadUpdate).not.toHaveBeenCalled() + expect(f.quitAndInstall).not.toHaveBeenCalled() + } }) diff --git a/apps/desktop/tests/updates/electron-updater/qualification.mjs b/apps/desktop/tests/updates/electron-updater/qualification.mjs index c8243d5480..83eeb6a3f9 100644 --- a/apps/desktop/tests/updates/electron-updater/qualification.mjs +++ b/apps/desktop/tests/updates/electron-updater/qualification.mjs @@ -60,11 +60,19 @@ async function main() { await target.checkForUpdates() assert.equal(requests.at(-1), `/stable${process.platform === 'darwin' ? '-mac' : ''}.yml`) assert.equal(target.allowDowngrade, false) - assert.equal(target.allowPrerelease, false) - version = '0.9.0' - await discardDesktopUpdate(target) - await target.checkForUpdates() - assert.equal(target.updateInfoAndProvider, null) + assert.equal(target.allowPrerelease, true) + for (const candidate of ['2.0.0-alpha.1', '2.0.0-rc.1', '2.0.0']) { + version = candidate + await discardDesktopUpdate(target) + assert.equal((await target.checkForUpdates()).isUpdateAvailable, true) + assert.equal(target.updateInfoAndProvider.info.version, candidate) + } + for (const candidate of ['0.9.0-rc.1', '1.0.0-rc.1']) { + version = candidate + await discardDesktopUpdate(target) + assert.equal((await target.checkForUpdates()).isUpdateAvailable, false) + assert.equal(target.updateInfoAndProvider, null) + } version = '2.0.0' percentage = 0 await discardDesktopUpdate(target) @@ -98,7 +106,7 @@ async function main() { // A later process can encounter files that a previous process could not remove. await writeFile(file, oldPayload) await writeFile(join(pending, 'update-info.json'), cachedInfo) - version = '2.1.0' + version = '2.1.0-rc.1' payload = Buffer.from('different release after obsolete cache survived') sha512 = createHash('sha512').update(payload).digest('base64') const recovered = new Target(undefined, adapter) @@ -115,7 +123,7 @@ async function main() { await recovered.checkForUpdates() await recovered.downloadUpdate() assert.deepEqual(await readFile(recovered.downloadedUpdateHelper.file), payload) - assert.equal(recovered.downloadedUpdateHelper.versionInfo.version, '2.1.0') + assert.equal(recovered.downloadedUpdateHelper.versionInfo.version, '2.1.0-rc.1') console.log('PASS: obsolete cache rejected by a new updater instance; current payload verified') console.log('PASS: real provider channel switching, rollout eligibility, real downloads, native platform cache and proxy retirement') } finally { diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts index 08347912ec..2fc04b5590 100644 --- a/apps/desktop/tests/welcome-flow.e2e.ts +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -5,25 +5,15 @@ import { createServer } from 'node:http' import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { DesktopHostProcess } from '../src/host-process.ts' -import { DesktopProjectManager } from '../src/project-manager.ts' -import { resolveDesktopPaths } from '../src/paths.ts' import { desktopClientMetadata } from '../src/client-metadata.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts' import { authenticateWebHost } from '../src/web-document.ts' -import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { prepareDevelopmentProject } from '../scripts/development-project.ts' +import { builtHost, prepareDesktopHome } from './harness.ts' -const repository = fileURLToPath(new URL('../../../', import.meta.url)) -const builtHost = join(repository, 'apps/desktop-host/lib/index.js') afterEach(() => { vi.unstubAllEnvs() }) -function version(path: string): string { - return (JSON.parse(readFileSync(path, 'utf8')) as { version: string }).version -} - async function mockPlatform() { let origin = '' let init: Record = {} @@ -85,49 +75,21 @@ async function mockPlatform() { describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { it('persists explicit API keys and browser account login independently across Host restarts', async () => { - vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-welcome-')) let host: DesktopHostProcess | undefined const platform = await mockPlatform() try { - for (const name of Object.keys(process.env)) { - if (/KEY|TOKEN|SECRET|PASSWORD/u.test(name)) vi.stubEnv(name, undefined) - } - const home = join(root, 'home') - mkdirSync(home) - vi.stubEnv('DSH_HOME', home) - vi.stubEnv('DSH_TELEMETRY_MODE', 'DISABLED') - const project = prepareDevelopmentProject({ - projectDir: join(root, 'project'), - cliDir: join(repository, 'apps/cli'), - hostDir: join(repository, 'apps/desktop-host'), - dependencyDir: join(repository, 'node_modules/.pnpm/node_modules'), - release: { - schemaVersion: 1, - version: version(join(repository, 'apps/desktop/package.json')), - pnpmVersion: version(join(repository, 'apps/desktop/node_modules/pnpm/package.json')), - nodeVersion: process.versions.node, - hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, - }, - // This suite assembles a synthetic project on hosts that prepare no Desktop target, and - // nothing it exercises compares the descriptor's platform/arch outside packaging. - target: 'mac-x64', - }) - cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true }) + const { home, project, paths } = await prepareDesktopHome(root) const nodeBin = join(root, 'runtime/primary-runtime/dependencies/node/bin') mkdirSync(nodeBin, { recursive: true }) cpSync(process.execPath, join(nodeBin, process.platform === 'win32' ? 'node.exe' : 'node')) - const paths = resolveDesktopPaths(home) - const manager = new DesktopProjectManager(paths, { - dsh: project, - }) - await manager.applyRelease() writeFileSync(join(paths.profile, 'cordis.patch.yml'), `- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n- id: deepseek-account\n config:\n${process.platform === 'linux' ? ' desktopPlatform: darwin\n' : ''} platformOrigin: ${platform.origin}\n allowLoopbackHttp: true\n requestHeaders:\n Cookie: test_gate=synthetic\n`) let backend: DesktopWelcomeBackend let hostOrigin = '' const restart = async (): Promise => { await host?.stop() - host = new DesktopHostProcess(process.execPath, project, paths.profile) + host = new DesktopHostProcess(process.execPath, project, paths.profile, undefined, + process.env, undefined, join(root, 'runtime', 'primary-runtime')) const { url } = await host.start() hostOrigin = new URL(url).origin backend = connectDesktopWelcome(url, await authenticateWebHost(url)) diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index ba417ebce3..abdb8d29c5 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -266,7 +266,7 @@ it.each([false, true])('unpacks platform ripgrep executables with external sourc it.each([false, true])('keeps the complete Office engine outside ASAR with external source=%s', async (external) => { const input = await fixture(external) const engine = join('node_modules', '@deepseek-ai', 'libreoffice-kit-win32-x64') - const files = ['package.json', 'prebuilds.json', 'bin/libreoffice-kit', 'program/registry/main.xcd'] + const files = ['package.json', 'prebuilds.json', 'bin/dsoffice', 'program/registry/main.xcd'] for (const file of files) { const path = join(input.source, engine, file) await mkdir(dirname(path), { recursive: true }) diff --git a/apps/plugin-market-generator/package.json b/apps/plugin-market-generator/package.json index 133e2d2931..5ea6d10246 100644 --- a/apps/plugin-market-generator/package.json +++ b/apps/plugin-market-generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market-generator", "description": "Reference generator of plugin market index files from a list of npm packages and Git repositories", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 8d48aff419..5ac5400998 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -17,9 +17,9 @@ }, "files": [ "dist", - "!dist/**/*.map", "!dist/preview.html", - "!dist/preview" + "!dist/preview", + "src" ], "scripts": { "build": "vite build", diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 6da971cf03..a68c1908a7 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -420,7 +420,7 @@ describe('web e2e: agent teamwork panel', () => { taskId: task.id, expectedRevision: task.revision, action: 'claim', }) await row.getByRole('img', { name: 'In progress', exact: true }).waitFor() - await row.locator('[data-team-task-owner]').filter({ hasText: agent.id }).waitFor() + await row.locator('[data-team-task-owner]').filter({ hasText: 'Owner: lead' }).waitFor() await action.getByRole('heading', { name: 'Shared tasks 1 in progress', exact: true }).waitFor() expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0) expect(await action.locator('input, select, textarea').count()).toBe(0) @@ -762,7 +762,7 @@ describe('web e2e: agent teamwork panel', () => { expect(await row.getAttribute('data-expanded')).toBe('false') expect(await row.locator('[data-team-task-details]').count()).toBe(0) expect(await board.locator('[data-team-task-details]').count()).toBe(1) - expect(await emptyRow.locator('[data-team-task-owner]').textContent()).toBe(`Owner: ${agent.id}`) + expect(await emptyRow.locator('[data-team-task-owner]').textContent()).toBe('Owner: lead') expect(await emptyRow.locator('[data-team-task-details] p:not([data-team-task-owner])').textContent()).toBe(completed.description) expect(await emptyRow.locator('dl, dt, dd').count()).toBe(0) expect(await emptyRow.locator('[data-team-task-details]').textContent()).not.toContain(completed.id) diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index 78d959375b..9b2c2b650f 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -1,6 +1,7 @@ /** Context gestures against built plugins, seeded Sessions, and original files. */ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Locator, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' @@ -38,7 +39,10 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', let scaffold: WebScaffold, browser: Browser, page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]) }) + scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), + extraOverlayPath: fileURLToPath(new URL('./context-menus.overlay.yml', import.meta.url)), + }) await mkdir(join(scaffold.workspaceCwd, 'folder')) await scaffold.ctx.workspaceRegistry.create(join(scaffold.workspaceCwd, 'folder'), 'Menu workspace') await writeFile(join(scaffold.workspaceCwd, 'first.txt'), 'FIRST\nSECOND\nTHIRD\n') @@ -70,7 +74,8 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', const menu = page.getByRole('menu') await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() const names = await menuLabels(menu) - expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder']) + expect(names.slice(0, 4)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID']) + expect(names).not.toContain('Open in') expect(names.at(-1)).toBe('Archive session') expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true) expect(await menu.getByRole('separator').count()).toBe(2) diff --git a/apps/web/tests/context-menus.overlay.yml b/apps/web/tests/context-menus.overlay.yml new file mode 100644 index 0000000000..ca2581fa55 --- /dev/null +++ b/apps/web/tests/context-menus.overlay.yml @@ -0,0 +1,4 @@ +# Context gestures do not query the runner's native file associations. +- id: session-controller + config: + nativeOpen: false diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index e164ae38e5..a34746d8ba 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -1661,8 +1661,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { ...['doc', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')), ]) const releaseConversion = Promise.withResolvers() - const convertOffice = scaffold.ctx.officeToPdf.convert.bind(scaffold.ctx.officeToPdf) - const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert').mockImplementationOnce(async (...args) => { + const convertOffice = scaffold.ctx.officeKit.convert.bind(scaffold.ctx.officeKit) + const convert = vi.spyOn(scaffold.ctx.officeKit, 'convert').mockImplementationOnce(async (...args) => { await releaseConversion.promise return convertOffice(...args) }) @@ -1682,7 +1682,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await successShot(page, `office-loading-${colorScheme}`) } const pdfResponse = page.waitForResponse( - response => new URL(response.url()).pathname === '/api/officeToPdf/render', + response => new URL(response.url()).pathname === '/api/officeKit/render', { timeout: 60_000 }, ) releaseConversion.resolve(undefined) diff --git a/apps/web/tests/expected/context-menus/session.txt b/apps/web/tests/expected/context-menus/session.txt index 5468d20e51..ac477e2304 100644 --- a/apps/web/tests/expected/context-menus/session.txt +++ b/apps/web/tests/expected/context-menus/session.txt @@ -2,5 +2,4 @@ Pin session Rename Fork session Copy Session ID -Show in folder Archive session diff --git a/apps/web/tests/expected/context-menus/ssh-header.txt b/apps/web/tests/expected/context-menus/ssh-header.txt new file mode 100644 index 0000000000..00155b9379 --- /dev/null +++ b/apps/web/tests/expected/context-menus/ssh-header.txt @@ -0,0 +1,5 @@ +- menu: + - menuitem "Copy Session as Markdown" + - menuitem "Download session log" + - separator + - menuitem "Feedback" diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 08a0750772..93993bbe04 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -23,6 +23,12 @@ - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - button "调整模型顺序 1" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 5d29a1bb88..b0546d4998 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -24,6 +24,7 @@ - button "Completed in {{duration}}" [expanded] - button "Injected context" [expanded] - button "Context injection runtime-context" +- button "Context injection skill-catalog" - button "Context injection time-context" - paragraph: "Review complete: no actionable findings." - button "Copy" 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 0f6c28c0f2..3022f22a78 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -28,6 +28,12 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.openai.com/v1 + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" 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 8fe2a7d83c..85aa3923d0 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" [selected] - option "OpenAI Responses" - option "Anthropic Messages" + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" 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 ed9c82a0c4..9645929155 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md index 46babcd11e..2f573f6e53 100644 --- a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md index b1f780f9b3..5e0855c653 100644 --- a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 03900f109b..a7be30e8c3 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -23,6 +23,12 @@ - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - button "调整模型顺序 1" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 27ba737236..b369a619b4 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -23,6 +23,12 @@ - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/open-in-app-menus/global-export.expected.md b/apps/web/tests/expected/open-in-app-menus/global-export.expected.md new file mode 100644 index 0000000000..b1e0c986d9 --- /dev/null +++ b/apps/web/tests/expected/open-in-app-menus/global-export.expected.md @@ -0,0 +1,5 @@ +- dialog "Exporting Session": + - heading "Exporting Session" [level=2] + - button "Close" + - paragraph: "Session: open-app-menu-foreground Preparing a ZIP containing the selected Session, its sub-Sessions, and attachments." + - button "Close" diff --git a/apps/web/tests/expected/plugin-config/web-search.expected.md b/apps/web/tests/expected/plugin-config/web-search.expected.md index 7f725a72e5..943e8da902 100644 --- a/apps/web/tests/expected/plugin-config/web-search.expected.md +++ b/apps/web/tests/expected/plugin-config/web-search.expected.md @@ -4,7 +4,7 @@ - region "网页搜索提供方": - heading "网页搜索提供方" [level=4] - button "网页搜索提供方 DeepSeek Search API": DeepSeek Search API - - paragraph: 直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费 + - paragraph: 会产生费用,由「计费方式」选定的账号或 API Key 支付。 - paragraph: - link "网页搜索与获取指南": - /url: https://api-docs.deepseek.com/zh-cn/guides/search @@ -12,9 +12,15 @@ - textbox "每次搜索结果数": - /placeholder: "" - text: "10" - - paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。 + - paragraph: 每次搜索返回的结果条数,1–50,默认 10。 + - text: 计费方式 + - button "计费方式 自动": 自动 + - paragraph: 跟随当前会话的 DeepSeek 账号或 API Key;会话用其他模型时,优先用 API Key,没有则用已登录账号。 - region "网页获取提供方": - heading "网页获取提供方" [level=4] - button "网页获取提供方 DeepSeek Fetch API": DeepSeek Fetch API - - paragraph: 由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机 + - paragraph: 无法访问内网和本机。会产生费用,由「计费方式」选定的账号或 API Key 支付。 + - text: 计费方式 + - button "计费方式 自动": 自动 + - paragraph: 跟随当前会话的 DeepSeek 账号或 API Key;会话用其他模型时,优先用 API Key,没有则用已登录账号。 - button "保存" [disabled] diff --git a/apps/web/tests/expected/retained-steering/trajectory.expected.md b/apps/web/tests/expected/retained-steering/trajectory.expected.md index 14696c5ae7..1cf298260c 100644 --- a/apps/web/tests/expected/retained-steering/trajectory.expected.md +++ b/apps/web/tests/expected/retained-steering/trajectory.expected.md @@ -9,6 +9,9 @@ - 'row "CONTEXT, The following information describes your current session: Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform te…"': - cell "CONTEXT" - 'cell " The following information describes your current session: Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform te…"' + - 'row "CONTEXT, A skill is a reusable set of task-specific instructions. The following skills are available in this session: office: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step. office-docx: Create, read, edit, and validate Word documents (.docx). office-pptx: Create, read, edit, and validate PowerPoint presentations (.pptx). office-xlsx: Read, cr…"': + - cell "CONTEXT" + - 'cell " A skill is a reusable set of task-specific instructions. The following skills are available in this session: office: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step. office-docx: Create, read, edit, and validate Word documents (.docx). office-pptx: Create, read, edit, and validate PowerPoint presentations (.pptx). office-xlsx: Read, cr…"' - 'row "CONTEXT, Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."': - cell "CONTEXT" - 'cell "Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."' diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md index 6724e2373d..f3836b2d55 100644 --- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -4,6 +4,6 @@ - menuitem "Fork session" - separator - menuitem "Copy Session ID" - - menuitem "Show in Finder" + - menuitem "Open in" - separator - menuitem "Archive session" diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 6bd1adcfbd..4c06982b08 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -15,6 +15,7 @@ - button "Completed in {{duration}}" [expanded] - button "Injected context" [expanded] - button "Context injection runtime-context" +- button "Context injection skill-catalog" - button "Context injection user-invoke-demo" - button "Context injection time-context" - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index a3a0ed7930..dd9afda70c 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -10,6 +10,7 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "Context injection runtime-context" +- button "Context injection skill-catalog" - button "Context injection time-context" - button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - status: Deep diving diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 10873c5f69..3977249952 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -13,6 +13,7 @@ - button "Completed in {{duration}}" [disabled] - button "Asked questions" [expanded] - button "Context injection runtime-context" +- button "Context injection skill-catalog" - button "Context injection time-context" - button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - button "Ask question 1/1 answered" diff --git a/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md b/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md index 76df6f6c57..3474a2e8dd 100644 --- a/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md +++ b/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md @@ -1,8 +1,8 @@ - dialog "Subagent sessions": - tree "Subagent sessions": - - treeitem "event-sourcing researcher {{uuid}} · continuable · not running 15.7K tok · {{duration}}" [level=1]: + - treeitem "event-sourcing researcher agent-1 · continuable · not running 15.7K tok · {{duration}}" [level=1]: - button "Expand event-sourcing researcher descendants" - - text: event-sourcing researcher {{uuid}} 15.7K tok {{duration}} + - text: event-sourcing researcher agent-1 15.7K tok {{duration}} - treeitem "event-sourcing reviewer recorded-one-shot · one-shot · completed 0 tok · {{duration}}" [level=1]: - text: event-sourcing reviewer recorded-one-shot 0 tok ~6mo 12d - button "Open event-sourcing reviewer in sidebar" diff --git a/apps/web/tests/expected/subagent-result-notice/en.expected.md b/apps/web/tests/expected/subagent-result-notice/en.expected.md index a44d176b78..bd1394eded 100644 --- a/apps/web/tests/expected/subagent-result-notice/en.expected.md +++ b/apps/web/tests/expected/subagent-result-notice/en.expected.md @@ -2,37 +2,37 @@ - text: Subagent run completed - time: {{clock}} - paragraph: This notification triggered this response. -- text: Background subagent notice-result-child finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. - button "Subagent run stopped {{clock}}" [expanded]: - text: Subagent run stopped - time: {{clock}} - paragraph: This notification triggered this response. -- text: Background subagent notice-result-child was stopped before it finished.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 was stopped before it finished.Its closing message:## Research result The original child reply stays available. - button "Subagent run failed {{clock}}" [expanded]: - text: Subagent run failed - time: {{clock}} - paragraph: This notification triggered this response. -- text: Background subagent notice-result-child failed before it finished.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 failed before it finished.Its closing message:## Research result The original child reply stays available. - button "Subagent reached the token limit {{clock}}" [expanded]: - text: Subagent reached the token limit - time: {{clock}} - paragraph: This notification triggered this response. -- text: Background subagent notice-result-child ran out of room before it finished.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 ran out of room before it finished.Its closing message:## Research result The original child reply stays available. - button "Subagent declined the task {{clock}}" [expanded]: - text: Subagent declined the task - time: {{clock}} - paragraph: This notification triggered this response. -- text: Background subagent notice-result-child declined the task.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 declined the task.Its closing message:## Research result The original child reply stays available. - button "Subagent run completed {{clock}}" [expanded]: - text: Subagent run completed - time: {{clock}} - paragraph: This notification triggered this response. -- text: Background subagent notice-result-child finished and will do no further work unless you send it more.It left no closing message. +- text: Background subagent agent-1 finished and will do no further work unless you send it more.It left no closing message. - button "Subtask status updated {{clock}}" [expanded]: - text: Subtask status updated diff --git a/apps/web/tests/expected/subagent-result-notice/zh.expected.md b/apps/web/tests/expected/subagent-result-notice/zh.expected.md index d26df1f590..a6e893576a 100644 --- a/apps/web/tests/expected/subagent-result-notice/zh.expected.md +++ b/apps/web/tests/expected/subagent-result-notice/zh.expected.md @@ -2,37 +2,37 @@ - text: 子智能体已完成任务 - time: {{clock}} - paragraph: 这条通知触发了本轮回复。 -- text: Background subagent notice-result-child finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. - button "子智能体任务已停止 {{clock}}" [expanded]: - text: 子智能体任务已停止 - time: {{clock}} - paragraph: 这条通知触发了本轮回复。 -- text: Background subagent notice-result-child was stopped before it finished.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 was stopped before it finished.Its closing message:## Research result The original child reply stays available. - button "子智能体执行失败 {{clock}}" [expanded]: - text: 子智能体执行失败 - time: {{clock}} - paragraph: 这条通知触发了本轮回复。 -- text: Background subagent notice-result-child failed before it finished.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 failed before it finished.Its closing message:## Research result The original child reply stays available. - button "子智能体已达 token 上限 {{clock}}" [expanded]: - text: 子智能体已达 token 上限 - time: {{clock}} - paragraph: 这条通知触发了本轮回复。 -- text: Background subagent notice-result-child ran out of room before it finished.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 ran out of room before it finished.Its closing message:## Research result The original child reply stays available. - button "子智能体拒绝执行 {{clock}}" [expanded]: - text: 子智能体拒绝执行 - time: {{clock}} - paragraph: 这条通知触发了本轮回复。 -- text: Background subagent notice-result-child declined the task.Its closing message:## Research result The original child reply stays available. +- text: Background subagent agent-1 declined the task.Its closing message:## Research result The original child reply stays available. - button "子智能体已完成任务 {{clock}}" [expanded]: - text: 子智能体已完成任务 - time: {{clock}} - paragraph: 这条通知触发了本轮回复。 -- text: Background subagent notice-result-child finished and will do no further work unless you send it more.It left no closing message. +- text: Background subagent agent-1 finished and will do no further work unless you send it more.It left no closing message. - button "子任务状态更新 {{clock}}" [expanded]: - text: 子任务状态更新 diff --git a/apps/web/tests/file-browser.e2e.ts b/apps/web/tests/file-browser.e2e.ts index 0032ed22ef..b23b531ee1 100644 --- a/apps/web/tests/file-browser.e2e.ts +++ b/apps/web/tests/file-browser.e2e.ts @@ -166,7 +166,7 @@ it('browses workspace files in tabs that share one pane tree', async () => { expect(await toggle.getAttribute('aria-expanded')).toBe('true') await toggle.focus() - // In dark mode the card takes the filled tree's background, with a soft white shadow. + // In dark mode the card is raised by a lighter background layer than the filled tree, and its soft shadow stays black. const theme = await page.locator('body').getAttribute('data-ds-dark-theme') try { await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) @@ -174,10 +174,10 @@ it('browses workspace files in tabs that share one pane tree', async () => { background: getComputedStyle(node).backgroundColor, shadow: getComputedStyle(node).boxShadow, })) - expect(cardPaint.shadow).toContain('color(srgb 1 1 1 / 0.08)') + expect(cardPaint.shadow).toContain('rgba(0, 0, 0, 0.04) 0px 3px 8px 0px') await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) await column.locator('[data-files-navigator-mode="fill"]:visible').waitFor() - expect(await navigator.evaluate(node => getComputedStyle(node).backgroundColor)).toBe(cardPaint.background) + expect(await navigator.evaluate(node => getComputedStyle(node).backgroundColor)).not.toBe(cardPaint.background) await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' }) await navigator.getByRole('separator').waitFor() } finally { diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index a0259aa131..d5c560e4cf 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -104,7 +104,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(Math.abs( launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, )).toBeLessThan(1) - await writeComposerDraft(page, input, '/cpt') + await writeComposerDraft(page, input, '/cmpt') await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([ 'CompactCompact older conversation history', ]) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 1eedb317fd..aef7c3c699 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -27,7 +27,7 @@ import { compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -414,15 +414,16 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { expect(tripwire.warnings).toEqual([]) }, 120_000) - it.skipIf(MODE === 'record')('surfaces the terminal turn error after transient retries exhaust', async () => { + it.skipIf(MODE === 'record')('continues after transient retries exhaust without another user bubble', async () => { // A whole-script replacement: three throw entries cover the first request // plus both budgeted retries (patches cannot reach past the one-call // derived script). The scenario-owned policy keeps exhaustion fast and // jitter-free instead of walking the shared default's five backed-off // attempts. const failure: ReplayEntry = { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } + const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) await launch( - () => [failure, failure, failure], + () => [failure, failure, failure, ...derived], { mode: 'normal', maxRetries: 2, retryableCodes: ['SERVER'], backoff: { initialDelayMs: 25, maxDelayMs: 50, jitterRatio: 0 } }, ) onTestFailed(() => saveFailureShot(page, 'web-e2e-retry-exhausted')) @@ -443,6 +444,49 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // context; the golden pins both. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(RETRY_EXHAUSTED_EXPECTED, snapshot, MODE) + await page.reload({ waitUntil: 'load' }) + const resume = page.getByRole('button', { name: 'Continue generating', exact: true }) + await resume.waitFor() + const input = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, input, '/plan ') + await input.press('Enter') + const plan = page.getByRole('button', { name: 'Plan mode on, press to turn off', exact: true }) + await plan.waitFor() + await expect.poll(() => input.textContent()).toBe('') + await plan.click() + await plan.waitFor({ state: 'hidden' }) + await resume.waitFor() + expect(await resume.isEnabled()).toBe(true) + expect(turnEndReasons(sessionEvents)).toEqual(['error']) + await writeComposerDraft(page, input, 'A different instruction') + expect(await resume.count()).toBe(0) + expect(await page.getByRole('button', { name: 'Send message', exact: true }).isEnabled()).toBe(true) + await writeComposerDraft(page, input, '') + await resume.waitFor() + await page.setViewportSize({ width: 390, height: 844 }) + // ResizeObserver collapses the model label after the viewport changes. + await expect.poll(async () => { + const bounds = await resume.boundingBox() + return bounds !== null && bounds.x + bounds.width <= 390 + }).toBe(true) + expect(await resume.isEnabled()).toBe(true) + const continued = scaffold!.whenTurnSettled() + await resume.click() + await continued + expect(turnEndReasons(sessionEvents)).toEqual(['error', 'completed']) + expect(sessionEvents.some(event => event.type === 'user/message' && event.data.source.kind === 'plan-mode')).toBe(true) + const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' + ? [event.data] : []) + expect(userMessages.map(message => message.content)).toEqual([[{ type: 'text', text: PROMPT }]]) + const retries = sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'retry') + expect(retries).toHaveLength(1) + expect(retries[0]?.data).toMatchObject({ content: [], source: { kind: 'retry' } }) + expect(sessionEvents.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(sessionEvents.filter(event => event.type === 'turn/end')).toHaveLength(2) + expect(await resume.count()).toBe(0) + await page.setViewportSize({ width: 1440, height: 900 }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'continued.expected.md'), + await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd), MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) @@ -451,7 +495,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md', 'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md', - 'retry.expected.md', 'retry-expanded.expected.md', 'retry-request.expected.md', 'retry-exhausted.expected.md', + 'retry.expected.md', 'retry-expanded.expected.md', 'retry-request.expected.md', 'retry-exhausted.expected.md', 'continued.expected.md', ]) }) }) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index d36008959d..58d76d04f1 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -6,8 +6,8 @@ // while the settings document records only that reference. Each saved row // appears after route topology invalidation without presenting liveness as // provider status. The customized-settings fold writes its curated fields — -// the endpoint, a declared route's own name and protocol, and the reasoning -// levels of its models — as merge patches against the stored profile. Zero +// the endpoint, the retry policy, a declared route's own name and protocol, and +// the reasoning levels of its models — as merge patches against the stored profile. Zero // model calls: configuration is pure settings/credentials/llm-domain traffic, // so there is no fixture and a stray stream would fail loud because the // adapter registry is empty. The provider under test is @@ -228,6 +228,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const url = dialog.getByLabel('API 地址') await url.waitFor({ timeout: 10_000 }) await url.fill('https://gateway.minimax.example/v1') + await dialog.getByRole('combobox', { name: '重试策略', exact: true }).selectOption('normal') + await dialog.getByLabel('最大重试次数', { exact: true }).fill('3') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The editor closes back to the row; the fold's write merged into the // stored profile beside the reference. @@ -236,6 +238,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(document).toContain('baseURL: https://gateway.minimax.example/v1') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') + expect(document).toMatch(/retryPolicy:\n\s+mode: normal\n\s+maxRetries: 3\n/u) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) @@ -637,8 +640,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await toggle.click() expect(await name.inputValue()).toBe('手机上的完整显示名称') const expandedName = await name.boundingBox() - if (expandedName === null) throw new Error('Expanded model name is not visible') - expect(expandedName.y).toBeGreaterThanOrEqual(compactId.y + compactId.height) + // Expanding can scroll the dialog, so the ID is measured again in the same frame. + const expandedId = await id.boundingBox() + if (expandedName === null || expandedId === null) throw new Error('Expanded model fields are not visible') + expect(expandedName.y).toBeGreaterThanOrEqual(expandedId.y + expandedId.height) expect(expandedName.width).toBeGreaterThan(compactId.width) expect(await dialog.getByRole('region', { name: '模型目录', exact: true }) .evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) diff --git a/apps/web/tests/official-subagent-bundle.e2e.ts b/apps/web/tests/official-subagent-bundle.e2e.ts index ef6268cd42..8dad28b2a1 100644 --- a/apps/web/tests/official-subagent-bundle.e2e.ts +++ b/apps/web/tests/official-subagent-bundle.e2e.ts @@ -141,7 +141,7 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl const firstTurnEvents = agent.session.snapshotEvents() const toolResults = firstTurnEvents.filter(event => event.type === 'tool/result').map(event => event.data) expect(toolResults, `PTC execution must start the native child before waiting for its result: ${JSON.stringify(toolResults)}`) - .toMatchObject([{ message: { isError: false } }]) + .toMatchObject([{ message: { isError: false, content: [{ type: 'text', text: 'agent-1' }] } }]) const dispatches = firstTurnEvents.filter(event => event.type === 'tool/ptc-dispatch').map(event => event.data) expect(dispatches).toMatchObject([{ name: 'subagent_claude_code', isError: false }]) const catalog = firstTurnEvents.filter(event => event.type === 'subagent/catalog').map(event => event.data) @@ -161,7 +161,7 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl if (notice?.data.source.kind !== 'subagent-settled') throw new Error('Native completion was not delivered to the parent') expect(notice.data.source.senderSessionId).toBe(catalog[0]?.childId) expect(notice.data.content).toContainEqual({ type: 'text', text: RESPONSE }) - expect(dispatches[0]?.content).toEqual([{ type: 'text', text: `started subagent ${notice.data.source.senderSessionId}` }]) + expect(dispatches[0]?.content).toEqual([{ type: 'text', text: 'started subagent agent-1' }]) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) expect(server.requests).toHaveLength(1) expect(server.requests[0]?.body).toMatchObject({ model: 'fixture-model' }) diff --git a/apps/web/tests/open-in-app-menus.e2e.ts b/apps/web/tests/open-in-app-menus.e2e.ts new file mode 100644 index 0000000000..c7c0bff288 --- /dev/null +++ b/apps/web/tests/open-in-app-menus.e2e.ts @@ -0,0 +1,324 @@ +/** Session menu opening over built plugins; only native application discovery and launch are simulated. */ +import { mkdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, webkit, type Browser, type BrowserContext, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' + +const FIRST = 'open-app-menu-foreground' +const OTHER = 'open-app-menu-background' +const ICON = '' +const devices = [ + { name: 'macOS desktop', platform: 'MacIntel', width: 1280, height: 800, touch: false, mobile: false }, + { name: 'Windows desktop', platform: 'Win32', width: 1280, height: 800, touch: false, mobile: false }, + { name: 'Linux desktop', platform: 'Linux x86_64', width: 1280, height: 800, touch: false, mobile: false }, + { name: 'narrow desktop', platform: 'MacIntel', width: 390, height: 844, touch: false, mobile: false }, + { name: 'iPhone', platform: 'iPhone', width: 390, height: 844, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' }, + { name: 'Android', platform: 'Linux armv8l', width: 412, height: 915, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' }, + { name: 'iPad portrait', platform: 'MacIntel', width: 820, height: 1180, touch: true, mobile: true }, + { name: 'iPad landscape', platform: 'MacIntel', width: 1180, height: 820, touch: true, mobile: true }, +] as const + +/** Choose the recorded Session through the real sidebar before testing viewport changes. */ +async function openSeed(page: Page, url: string): Promise { + await page.goto(url) + const group = page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' }) + await group.click() + await page.locator(`[data-row-key="session:${FIRST}"]`).click() + await page.getByText('DONE', { exact: true }).waitFor() +} + +/** Every visible parent and child menu stays inside the viewport, including long lists. */ +async function expectMenusInside(page: Page): Promise { + const bounds = await page.getByRole('menu').evaluateAll(nodes => nodes.map((node) => { + const rect = node.getBoundingClientRect() + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: innerWidth, height: innerHeight } + })) + expect(bounds.length).toBeGreaterThan(0) + for (const rect of bounds) { + expect(rect.left).toBeGreaterThanOrEqual(0) + expect(rect.top).toBeGreaterThanOrEqual(0) + expect(rect.right).toBeLessThanOrEqual(rect.width) + expect(rect.bottom).toBeLessThanOrEqual(rect.height) + } +} + +/** Install an instance-local native-operation simulation without changing the menu implementation. */ +async function nativeRoutes( + context: BrowserContext, + catalog: readonly string[], + launch: (body: { app: string; path: string }) => Promise, +): Promise { + await context.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: catalog } })) + await context.route('**/open-in-app/icon/*', route => route.fulfill({ body: ICON, contentType: 'image/svg+xml' })) + await context.route('**/open-in-app/open', async (route) => { + const status = await launch(route.request().postDataJSON() as { app: string; path: string }) + await route.fulfill({ status, json: { ok: status === 200 } }) + }) +} + +describe('Open in Session menus', () => { + let scaffold: WebScaffold + let browser: Browser + let otherDirectory: string + beforeAll(async () => { + // The real Host receives SSH facts to avoid probing or starting this runner's native applications. + scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([ + { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }, + ]) }) + const recorded = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + otherDirectory = join(scaffold.workspaceCwd, 'background-directory') + await mkdir(otherDirectory) + await seedSession(scaffold, recorded, FIRST) + await seedSession({ ...scaffold, workspaceCwd: otherDirectory }, recorded, OTHER) + browser = await chromium.launch() + }) + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it.each([true, false])('owns Header More without export, with Open in enabled=%s', async (enabled) => { + const isolated = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./open-in-app-no-export.overlay.yml', import.meta.url)), + ...(enabled ? { openInAppEnvironment: createLaunchEnvironmentSnapshot([ + { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }, + ]) } : {}), + }) + onTestFinished(() => isolated.close()) + const recorded = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + await seedSession(isolated, recorded, FIRST) + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) + onTestFinished(() => context.close()) + const launches: { app: string; path: string }[] = [] + await nativeRoutes(context, ['finder'], async (body) => { launches.push(body); return 200 }) + const page = await context.newPage() + await openSeed(page, isolated.authenticatedUrl) + const more = page.getByRole('button', { name: 'More actions', exact: true }) + if (!enabled) { + expect(await more.count()).toBe(0) + return + } + await more.click() + await page.getByRole('menuitem', { name: 'Open in', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: 'Download session log', exact: true }).count()).toBe(0) + expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).count()).toBe(0) + await more.click() + await expect.poll(() => page.getByRole('menu').count()).toBe(0) + await more.click() + await page.getByRole('menuitem', { name: 'Open in', exact: true }).click() + await page.getByRole('menuitem', { name: 'Finder', exact: true }).click() + await expect.poll(() => launches.length).toBe(1) + expect(launches[0]).toEqual({ app: 'finder', path: isolated.workspaceCwd }) + }) + + it('keeps export feedback across Session navigation and dismissal does not cancel the download', async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) + const gate = Promise.withResolvers() + const settled = Promise.withResolvers() + let requested = false + onTestFinished(async () => { + gate.resolve(undefined) + if (requested) await settled.promise + await context.close() + }) + await nativeRoutes(context, ['finder'], async () => 200) + await context.route('**/api/session.export?*', async (route) => { + if (route.request().method() === 'HEAD') { + requested = true + try { + await gate.promise + await route.fulfill({ status: 200 }) + } finally { settled.resolve(undefined) } + } else await route.fulfill({ status: 200, contentType: 'application/zip', body: 'controlled download response' }) + }) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Download session log', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Exporting Session', exact: true }) + await dialog.waitFor() + expect(await dialog.textContent()).toContain(`Session: ${FIRST} `) + const other = page.locator(`[data-row-key="session:${OTHER}"]`) + // Exercise navigation's real handler while the modal owns pointer hit-testing. + await other.dispatchEvent('click') + await expect.poll(() => other.getAttribute('aria-selected')).toBe('true') + expect(await dialog.textContent()).toContain(`Session: ${FIRST} `) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/open-in-app-menus/global-export.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), + ) + await dialog.getByText('Close', { exact: true }).click() + const downloaded = page.waitForEvent('download') + gate.resolve(undefined) + const download = await downloaded + expect(download.suggestedFilename()).toContain(FIRST) + expect(await page.getByRole('dialog').count()).toBe(0) + }) + + it.each(devices)('opens through Header More on $name with device-specific feedback', async (device) => { + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, locale: 'en-US', hasTouch: device.touch, + ...('userAgent' in device ? { userAgent: device.userAgent } : {}), + }) + onTestFinished(() => context.close()) + await context.addInitScript(({ platform, touch }) => { + Object.defineProperty(navigator, 'platform', { value: platform }) + Object.defineProperty(navigator, 'maxTouchPoints', { value: touch ? 5 : 0 }) + }, device) + const launches: { app: string; path: string }[] = [] + await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return 200 }) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + await page.setViewportSize({ width: device.width, height: device.height }) + const more = page.getByRole('button', { name: 'More actions', exact: true }) + expect(await page.getByRole('banner').locator('[data-open-target="directory"]').count()).toBe(0) + if (device.touch) await more.tap(); else await more.click() + const parent = page.getByRole('menuitem', { name: 'Open in', exact: true }) + await parent.waitFor() + if (device.touch) await more.tap(); else await more.click() + await expect.poll(() => page.getByRole('menu').count()).toBe(0) + expect(await more.getAttribute('aria-expanded')).toBe('false') + if (device.touch) await more.tap(); else await more.click() + await parent.waitFor() + expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).count()).toBe(1) + expect(await page.getByRole('menuitem', { name: 'Pin session', exact: true }).count()).toBe(0) + if (device.touch) await parent.tap(); else await parent.click() + await page.getByRole('menuitem', { name: 'Cursor', exact: true }).waitFor() + await expectMenusInside(page) + if (device.width === 390) { + expect(await page.getByRole('menuitem', { name: 'Back', exact: true }).count()).toBe(1) + } + const choice = page.getByRole('menuitem', { name: 'Cursor', exact: true }) + if (device.touch) await choice.tap(); else await choice.click() + await expect.poll(() => launches).toEqual([{ app: 'cursor', path: scaffold.workspaceCwd }]) + await expect.poll(() => page.getByRole('menu').count()).toBe(0) + await expect.poll(() => page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"cursor"') + if (device.mobile) await page.getByText('Opened on another device', { exact: true }).waitFor() + else expect(await page.getByText('Opened on another device', { exact: true }).count()).toBe(0) + }) + + it('captures a background Session, remembers only success, and prevents a second launch while busy', async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) + onTestFinished(() => context.close()) + const response = Promise.withResolvers() + onTestFinished(() => { response.resolve(502) }) + const launches: { app: string; path: string }[] = [] + await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return response.promise }) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + const background = page.locator(`[data-row-key="session:${OTHER}"]`) + await background.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Open in', exact: true }).click() + await page.getByRole('menuitem', { name: 'Cursor', exact: true }).click() + await expect.poll(() => launches).toEqual([{ app: 'cursor', path: otherDirectory }]) + expect(await background.getAttribute('aria-selected')).toBe('false') + await page.getByRole('button', { name: 'More actions', exact: true }).click() + const disabled = page.getByRole('menuitem', { name: 'Open in', exact: true }) + await expect.poll(() => disabled.isDisabled()).toBe(true) + expect(launches).toHaveLength(1) + await page.keyboard.press('Escape') + response.resolve(502) + await page.getByText('Could not open. Try again.', { exact: true }).waitFor() + expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBeNull() + await background.hover() + await background.getByRole('button', { name: /actions/i }).click() + await expect.poll(() => page.getByRole('menuitem', { name: 'Open in', exact: true }).isEnabled()).toBe(true) + expect(await page.getByRole('menuitem', { name: 'Show in Finder', exact: true }).count()).toBe(0) + }) + + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'tracks real geometry and ignores unrelated portals in $name', async ({ engine }) => { + const isolated = await engine.launch() + onTestFinished(() => isolated.close()) + const context = await isolated.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' }) + await nativeRoutes(context, ['vscode', 'cursor', 'finder', 'windowsterminal'], async () => 200) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'More actions', exact: true }).click() + const parent = page.getByRole('menuitem', { name: 'Open in', exact: true }) + await page.mouse.move(20, 750) + await parent.focus() + await page.keyboard.press('ArrowRight') + const root = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }) }) + const child = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Windows Terminal', exact: true }) }) + await child.waitFor() + const rootElement = await root.elementHandle() + const childElement = await child.elementHandle() + const parentElement = await parent.elementHandle() + if (rootElement === null || childElement === null || parentElement === null) throw new Error('Both menu panels and the parent row must be mounted') + const alignment = async () => { + const [outer, nested, row] = await Promise.all([rootElement.boundingBox(), childElement.boundingBox(), parentElement.boundingBox()]) + return outer === null || nested === null || row === null ? Infinity : Math.max( + Math.abs(outer.x - nested.x - nested.width - 4), Math.abs(nested.y - row.y), + ) + } + // offsetWidth is integer-valued while browser rectangles retain fractional pixels. + await expect.poll(alignment).toBeLessThanOrEqual(1) + + // Browser layout changes are real; no observer callback or rectangle is mocked. + await parent.evaluate((node) => { node.style.marginTop = '24px' }) + await expect.poll(alignment).toBeLessThanOrEqual(1) + const before = await child.boundingBox() + await page.getByRole('menuitem', { name: 'Windows Terminal', exact: true }).getByText('Windows Terminal', { exact: true }) + .evaluate((node) => { node.style.fontSize = '30px' }) + await expect.poll(async () => (await child.boundingBox())?.width ?? 0).toBeGreaterThan(before?.width ?? 0) + await expect.poll(alignment).toBeLessThanOrEqual(1) + + await rootElement.evaluate((node) => { + node.style.maxHeight = '130px' + const viewport = node.querySelector(':scope > [role="presentation"]') + if (viewport === null || viewport.scrollHeight <= viewport.clientHeight) throw new Error('The parent must scroll') + viewport.scrollTop = viewport.scrollHeight + }) + await expect.poll(alignment).toBeLessThanOrEqual(1) + await page.setViewportSize({ width: 390, height: 844 }) + await page.getByRole('menuitem', { name: 'Back', exact: true }).waitFor() + await expectMenusInside(page) + expect(await childElement.evaluate(node => node.isConnected)).toBe(true) + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + const writes = await childElement.evaluate(async (node) => { + let count = 0 + const observer = new MutationObserver((records) => { count += records.length }) + observer.observe(node, { attributes: true, attributeFilter: ['style'] }) + const unrelated = document.createElement('div') + unrelated.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none' + try { + document.body.append(unrelated) + await new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + }) + return count + } finally { observer.disconnect(); unrelated.remove() } + }) + expect(writes).toBe(0) + await page.getByRole('menuitem', { name: 'Back', exact: true }).click() + await parent.waitFor() + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('menu').count()).toBe(0) + }, + ) + + it.each([{ catalog: [] }, { catalog: ['unnameable'] }, { catalog: ['finder'] }])('keeps empty and single-application catalogs consistent: $catalog', async ({ catalog }) => { + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) + onTestFinished(() => context.close()) + await nativeRoutes(context, catalog, async () => 200) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor() + const open = page.getByRole('menuitem', { name: 'Open in', exact: true }) + if (catalog[0] !== 'finder') expect(await open.count()).toBe(0) + else { + await open.click() + await page.getByRole('menuitem', { name: 'Finder', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: /^Finder/ }).count()).toBe(1) + await expectMenusInside(page) + } + }) +}) diff --git a/apps/web/tests/open-in-app-no-export.overlay.yml b/apps/web/tests/open-in-app-no-export.overlay.yml new file mode 100644 index 0000000000..3503bab635 --- /dev/null +++ b/apps/web/tests/open-in-app-no-export.overlay.yml @@ -0,0 +1,3 @@ +# Header ownership must not depend on the optional export contributor. +- id: session-log-download + disabled: true diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index ff57285d88..52ca0d0f27 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -65,6 +65,12 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE) + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: 'Open in', exact: true }).count()).toBe(0) + await compareOrRefreshGolden(join(import.meta.dirname, 'expected/context-menus/ssh-header.txt'), + await captureStableAria(page, '[data-context-menu]', scaffold.workspaceCwd), MODE) + await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md']) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 2b317f4ec3..a8982010de 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -182,14 +182,15 @@ describe('web e2e: plugin configuration pages', () => { expect(await panel.getByRole('link', { name: '网页搜索与获取指南', exact: true }).getAttribute('href')) .toBe('https://api-docs.deepseek.com/zh-cn/guides/search') // Each section shows the description its selected provider registered with the Host, in the active language. - expect(await searchSection.getByText('直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', { exact: true }).count()).toBe(1) - expect(await fetchSection.getByText('本机直接请求,免 API Key 与计费,可访问内网和本机。仅解码 HTML、JSON 等文本,不执行 JavaScript、不带登录态,不支持 PDF;也可以试试 DeepSeek Fetch API', { exact: true }).count()).toBe(1) + expect(await searchSection.getByText('会产生费用,由「计费方式」选定的账号或 API Key 支付。', { exact: true }).count()).toBe(1) + expect(await fetchSection.getByText('免费,可访问内网和本机。不支持需要 JavaScript 或登录的页面,也不支持 PDF。', { exact: true }).count()).toBe(1) // The Fetch API suggestion is text, not an action. expect(await fetchSection.getByRole('button').count()).toBe(1) expect(await save.isDisabled()).toBe(true) - // The Search panel holds only the result count. + // The Search panel holds the result count and the billing menu, automatic by default. expect(await searchSection.getByRole('textbox').count()).toBe(1) + expect(await panel.locator('#plugin-config-web-search-billing').textContent()).toBe('自动') expect(await count.inputValue()).toBe('10') await count.fill('51') await panel.getByText('请填 1–50 的整数;留空表示使用默认值。', { exact: true }).waitFor() @@ -198,14 +199,22 @@ describe('web e2e: plugin configuration pages', () => { await count.fill('10') expect(await save.isDisabled()).toBe(true) - // The Fetch API takes no settings here, so its section shows no panel. + // The Fetch API shows only its billing menu, and the page saves the billing with the selection. await choose(fetchMenu, 'DeepSeek Fetch API') - expect(await fetchSection.getByText('由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机', { exact: true }).count()).toBe(1) + expect(await fetchSection.getByText('无法访问内网和本机。会产生费用,由「计费方式」选定的账号或 API Key 支付。', { exact: true }).count()).toBe(1) expect(await fetchSection.getByRole('textbox').count()).toBe(0) - expect(await fetchSection.getByRole('button').count()).toBe(1) + expect(await fetchSection.getByRole('button').count()).toBe(2) + const fetchBilling = panel.locator('#plugin-config-web-fetch-billing') + await choose(fetchBilling, 'DeepSeek API Key') + await fetchSection.getByText('使用「模型」页配置的 API Key,需要先配置。', { exact: true }).waitFor() await save.click() await expect.poll(() => save.isDisabled()).toBe(true) await expect.poll(settingsDocument).toContain('fetchProvider: deepseek-fetch-api') + await expect.poll(settingsDocument).toContain('fetchBilling: api-key') + await choose(fetchBilling, '自动') + await save.click() + await expect.poll(() => save.isDisabled()).toBe(true) + await expect.poll(settingsDocument).not.toContain('fetchBilling:') await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 1e0ae9dfad..ba6bcc0cca 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -305,7 +305,7 @@ class ShippedChildAutoAdapter extends LlmAdapter { throw new Error('continuable child id was not observed before parent adjustment') } return toolChunks('parent-adjust', 'send_message', { - agent_id: this.continuableChildId, + agent: this.continuableChildId, message: [ AUTO_CHILD_ADJUSTED, `Replace the earlier no-deletion restriction: permanently delete exactly ${this.continuableDeletePath}.`, @@ -485,6 +485,9 @@ const EXPECTED_TOOLS = [ 'job_output', 'list_agents', 'list_subagent_models', + 'office_convert', + 'office_recalculate', + 'office_render', 'plugin_manager', 'present', 'read', diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 9032c3413b..091b670513 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -11,4 +11,4 @@ - img "In progress" - text: Agent task - paragraph: Created by the root agent - - paragraph: "Owner: session-{{uuid}}" + - paragraph: "Owner: lead" diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 6162e9e477..94a7638c3f 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -10,6 +10,7 @@ - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy" - button "Context injection runtime-context" +- button "Context injection skill-catalog" - button "Context injection time-context" - text: ts - button "Wrap lines" [pressed] diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index a687c45f57..451fa72221 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -7,10 +7,11 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, type MockInstance } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, type MockInstance } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { expandAssistantStream } from '@deepseek-ai/dsh-llm' +import { expandAssistantStream, type UserMessage } from '@deepseek-ai/dsh-llm' import { parseRemoteStreamServerMessage } from '@deepseek-ai/dsh-api-gateway/stream-protocol' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, @@ -109,7 +110,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { } }) - it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => { + it('strictly steers one queued row and deduplicates its retry after claiming', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-steering')) if (MODE !== 'record') { // The steer lands as a durable user/message, so the inventory holds @@ -155,12 +156,34 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) } - // Answer the composer; the tool result closes the step, the loop drains - // the steer as user/message, and the steered continuation runs the - // final model call. - await composer.getByRole('radio', { name: 'Yes' }).click() - await composer.getByRole('radio', { name: 'Yes' }).press('Enter') - await settled + const claimed = Promise.withResolvers<{ agent: Agent; message: UserMessage }>() + const release = Promise.withResolvers() + const stopHolding = scaffold.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { + const message = messages.find(item => item.content.some(block => block.type === 'text' && block.text === STEER)) + if (message !== undefined) { + claimed.resolve({ agent, message }) + await release.promise + } + return next() + }) + onTestFinished(() => { release.resolve(undefined); stopHolding() }) + try { + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + const { agent, message } = await claimed.promise + expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0) + const source = message.source + if (source.kind !== 'user' || !('rpcId' in source)) throw new Error('steering input has no request identity') + await expect(scaffold.ctx.sessionController.prompt({ + sessionId: agent.id, requestId: source.rpcId, mode: 'queue', + content: [{ type: 'text', text: STEER }], + }, new AbortController().signal)).resolves.toEqual({ accepted: true }) + expect([...agent.inbox.nextTurn, ...agent.inbox.nextStep]).toEqual([]) + } finally { + release.resolve(undefined) + stopHolding() + await settled + } if (MODE === 'record') { const sessionId = await settled diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 2b9cae7b84..9690154afe 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -39,6 +39,8 @@ const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL( )) const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' +/** The live child's catalog name; it is the parent's first named child. */ +const CHILD_NAME = 'agent-1' const ONE_SHOT_LABEL = 'event-sourcing reviewer' const NESTED_LABEL = 'example editor' const CHILD_TITLE = 'Explain event sourcing in one' @@ -398,7 +400,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = } }) - it('shows complete descriptions and IDs without hover at desktop and touch widths', async () => { + it('shows complete descriptions and names without hover at desktop and touch widths', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-catalog-labels')) const originalViewport = page.viewportSize()! const originalUrl = page.url() @@ -410,7 +412,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const panel = page.locator('[data-subagent-panel]') const row = panel.getByRole('treeitem', { name: new RegExp(LABEL) }) const primary = row.getByText(LABEL, { exact: true }) - const secondary = row.getByText(childId, { exact: true }) + const secondary = row.getByText(CHILD_NAME, { exact: true }) const primaryBounds = await primary.boundingBox() const secondaryBounds = await secondary.boundingBox() expect(primaryBounds).not.toBeNull() @@ -420,7 +422,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await label.evaluate(element => element.scrollWidth <= element.clientWidth && element.scrollHeight <= element.clientHeight)).toBe(true) } - expect(await row.getAttribute('aria-label')).toContain(`${LABEL} ${childId} `) + expect(await row.getAttribute('aria-label')).toContain(`${LABEL} ${CHILD_NAME} `) expect(await row.getByRole('button', { name: /description/u }).count()).toBe(0) expect(await row.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).count()).toBe(1) expect(await row.getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).count()).toBe(1) @@ -776,7 +778,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem', { name: /Ask a research subagent to/ }) .click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(CHILD_NAME, { exact: true }).click() await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).waitFor() const forkResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/fork') @@ -805,7 +807,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(CHILD_NAME, { exact: true }).click() await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -822,7 +824,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '2 subagents' }).press('ArrowDown') - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(CHILD_NAME, { exact: true }).click() const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.waitFor() const promptResponse = page.waitForResponse(response => @@ -868,7 +870,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor() expect(await rootHeader.textContent()).toBe(progress) - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(CHILD_NAME, { exact: true }).click() const switcher = page.getByRole('button', { name: `Switch subagent: ${LABEL}`, exact: true }) await switcher.click() const panel = page.locator('[data-subagent-panel]') diff --git a/apps/web/tests/subagent-result-notice.e2e.ts b/apps/web/tests/subagent-result-notice.e2e.ts index cff9482035..cb8525f617 100644 --- a/apps/web/tests/subagent-result-notice.e2e.ts +++ b/apps/web/tests/subagent-result-notice.e2e.ts @@ -63,13 +63,13 @@ describe.skipIf(MODE === 'record')('web e2e: subagent result notices', () => { const childId = SessionId('notice-result-child') const notices: { title: string; message: ReturnType }[] = OUTCOMES.map(outcome => ({ title: outcome[locale], - message: createSettlementMessage(childId, { + message: createSettlementMessage(childId, 'agent-1', { stopReason: outcome.reason, output: [{ type: 'text', text: OUTPUT }], }), })) notices.push({ title: OUTCOMES[0][locale], - message: createSettlementMessage(childId, { stopReason: 'completed', output: [] }), + message: createSettlementMessage(childId, 'agent-1', { stopReason: 'completed', output: [] }), }) notices.push({ title: locale === 'en' ? 'Subtask status updated' : '子任务状态更新', diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 096c159be4..c6f16f287d 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -587,6 +587,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo await observe('details') await row.getByRole('button', { name: /^Session actions for / }).hover() await hoverPage.clock.runFor(1000) + expect(await tooltip.count()).toBe(0) expect(await card.count()).toBe(1) await observe('more-hover') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fb40d60bb7..ec47d94afe 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -187,6 +187,7 @@ "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/open-in-app-ssh.e2e.ts", + "tests/open-in-app-menus.e2e.ts", "tests/fork-mid-turn.e2e.ts", "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", diff --git a/benchmarks/long-session-browser/synthetic-history.ts b/benchmarks/long-session-browser/synthetic-history.ts index cc2ecec551..2426d61a4c 100644 --- a/benchmarks/long-session-browser/synthetic-history.ts +++ b/benchmarks/long-session-browser/synthetic-history.ts @@ -16,8 +16,8 @@ export const FIRST = 'SYNTHETIC_REPLY_FIRST' export const DONE = 'SYNTHETIC_REPLY_DONE' /** Paced text chunks per continuation. */ export const DELTAS = 120 -/** Replay delay per stream chunk, in milliseconds. */ -export const PACE_MS = 16 +/** Replay delay per stream chunk, in milliseconds; keeps the input-overlap probe inside the stream on loaded CI runners. */ +export const PACE_MS = 32 /** Create mixed prose, code, reasoning and tool history without reading user data. * @returns Current Session JSONL accepted by the shared Web seeder. diff --git a/benchmarks/package.json b/benchmarks/package.json index 4601a6e972..1978b41bd1 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "zod": "^4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 9536ef05f7..6c6a6fe234 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: 81ada544b91db085 - zh: 4442cc22a114987d + en: 915bfe6e90876dfe + zh: 045adacedb3f3b3e diff --git a/docs/capability-seams.md b/docs/capability-seams.md index f3f73f43b6..66f3a44c96 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -68,9 +68,10 @@ flowchart LR 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_office_kit["office-kit"] + svc_officeKit["ctx.officeKit
Office rendering, conversion, and recalculation"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] + pkg_tool_office["tool-office"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -405,7 +406,7 @@ flowchart LR pkg_mcp_manager --> svc_mcpManager pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback - pkg_office_to_pdf --> svc_officeToPdf + pkg_office_kit --> svc_officeKit pkg_otel --> svc_otel pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode @@ -537,7 +538,8 @@ flowchart LR svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp svc_mcpResources --> pkg_mcp_resources - svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview + svc_officeKit --> pkg_client_ui_sidebar_documentpreview + svc_officeKit --> pkg_tool_office svc_otel --> pkg_host_product_telemetry_otel svc_otel --> pkg_session_telemetry_otel svc_pluginManager --> pkg_plugin_manager @@ -669,7 +671,7 @@ flowchart LR | `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.officeKit` | `core` | [`office-kit`](../packages/document/office-kit) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview), [`tool-office`](../packages/document/tool-office) | - | Authorized Office bytes are rendered as PDFs or images, converted, or recalculated 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. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 9b24db11e8..6cf44f2bd6 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -70,9 +70,10 @@ flowchart LR 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_office_kit["office-kit"] + svc_officeKit["ctx.officeKit
Office rendering, conversion, and recalculation"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] + pkg_tool_office["tool-office"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -407,7 +408,7 @@ flowchart LR pkg_mcp_manager --> svc_mcpManager pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback - pkg_office_to_pdf --> svc_officeToPdf + pkg_office_kit --> svc_officeKit pkg_otel --> svc_otel pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode @@ -539,7 +540,8 @@ flowchart LR svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp svc_mcpResources --> pkg_mcp_resources - svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview + svc_officeKit --> pkg_client_ui_sidebar_documentpreview + svc_officeKit --> pkg_tool_office svc_otel --> pkg_host_product_telemetry_otel svc_otel --> pkg_session_telemetry_otel svc_pluginManager --> pkg_plugin_manager @@ -671,7 +673,7 @@ flowchart LR | `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.officeKit` | `core` | [`office-kit`](../packages/document/office-kit) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview), [`tool-office`](../packages/document/tool-office) | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎渲染为 PDF 或图片、转换或重算;未声明原生目标时使用 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 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6d42bc33a7..77ccb897b6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -177,7 +177,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-team` - `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` -- `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) +- `source`: [`packages/team/agent-team/src/types.ts:136`](../packages/team/agent-team/src/types.ts) ```ts config-catalog /** Team-service defaults and deployment limits. */ @@ -961,8 +961,6 @@ export interface Config { mode: 'launch' | 'attach' /** CDP HTTP or WebSocket endpoint, required only for attach mode. */ cdpEndpoint?: string - /** Optional Stagehand extension id for an existing browser. */ - extensionId?: string /** Installed Chrome/Chromium executable used in launch mode. */ executablePath?: string /** Hide an owned browser's window. */ @@ -1231,7 +1229,7 @@ export interface InspectorOptions { readonly maxJournalBytes?: number /** Maximum active and completed fetch requests retained by the Worker. */ readonly maxRetainedRequests?: number - /** Maximum encoded bytes accepted in one source transport frame. */ + /** Maximum encoded bytes accepted in one source transport frame; values below 5121 leave no Cordis snapshot budget and fail. */ readonly maxSourceFrameBytes?: number /** Maximum observation records accepted in one source batch. */ readonly maxSourceRecordsPerFrame?: number @@ -2239,7 +2237,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable - - + + -## `@deepseek-ai/dsh-office-to-pdf` +## `@deepseek-ai/dsh-office-kit` -- `source`: [`packages/document/office-to-pdf/src/index.ts:31`](../packages/document/office-to-pdf/src/index.ts) +- `source`: [`packages/document/office-kit/src/index.ts:38`](../packages/document/office-kit/src/index.ts) ```ts config-catalog /** Provider concurrency and kit rendering/font configuration. */ @@ -2550,19 +2548,25 @@ export interface Config { maxSourceBytes: number /** Maximum concurrent background jobs; zero refuses speculative work. */ maxBackgroundConversions: number - /** Maximum retained content-addressed PDFs. */ + /** Maximum retained content-addressed outputs. */ maxCachedEntries: number - /** Maximum retained PDF bytes. */ + /** Maximum retained output bytes. */ maxCachedBytes: number /** Maximum retained source-version aliases to cached content. */ maxSourceEntries: number + /** Absolute directory for converted and recalculated files, with one subdirectory per Session. */ + outputDirectory: string + /** Milliseconds a converted or recalculated file is kept. */ + outputRetentionMs: number + /** Maximum bytes across retained converted and recalculated files; the oldest leave first. */ + maxOutputStoreBytes: number /** Conversion deadline in milliseconds; excludes the DSH queue. */ timeoutMs: number /** Maximum authorized source bytes. */ maxInputBytes: number - /** Maximum complete PDF bytes. */ + /** Maximum complete PDF, converted, or recalculated file bytes. */ maxOutputBytes: number - /** Exported raster-image DPI. */ + /** Image resolution in DPI of PDF exports; `renderImages()` takes its resolution from each request's `dpi`. */ maxImageResolution: number /** Maximum OOXML ZIP entries. */ maxArchiveEntries: number @@ -2580,7 +2584,7 @@ export interface Config { maxLoadedFontBytes: number } ``` - + @@ -2962,7 +2966,7 @@ export interface Config { - `inject`: `sessionProjections` - `refs`: [`SandboxMode`](subsystems/sandbox.md) -- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:94`](../packages/sandbox/sandbox-policy/src/index.ts) +- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:95`](../packages/sandbox/sandbox-policy/src/index.ts) ```ts config-catalog /** @@ -2986,7 +2990,7 @@ export interface Config { * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under - * `read-only`. + * `read-only`. Windows process confinement ignores them; harness file tools honor them. */ additionalWritableRoots?: string[] } @@ -3350,7 +3354,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:30`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -3422,18 +3426,28 @@ export interface Config { ## `@deepseek-ai/dsh-skill-office` - `inject`: `skills` -- `source`: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts) +- `source`: [`packages/skill/skill-office/src/index.ts:34`](../packages/skill/skill-office/src/index.ts) ```ts config-catalog -/** Office skill resource location. */ +/** Configuration after schema defaults apply. */ +export interface ResolvedConfig extends Config { + /** Python environment named in the Python authoring and verification skills. */ + pythonEnvironment: PythonEnvironment +} + +/** Office skill resources and Python environment. */ export interface Config { /** Absolute assets directory containing skill folders; defaults to packaged assets. */ assetRoot?: string - /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ - node?: string - /** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */ - cli?: string | false + /** Python environment named in the Python authoring and verification skills. @default 'workspace-dependencies' */ + pythonEnvironment?: PythonEnvironment } + +/** + * Python source for Office scripts: `'workspace-dependencies'` directs the model to the bundled interpreter and libraries + * returned by `load_workspace_dependencies`; `'user'` directs it to an existing project or user Python. + */ +export type PythonEnvironment = 'workspace-dependencies' | 'user' ``` @@ -3626,7 +3640,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:181`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -4237,6 +4251,31 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-tool-office` + +- `inject`: `tools` · `fs` · `workingDirectory` · `agents` +- `source`: [`packages/document/tool-office/src/index.ts:38`](../packages/document/tool-office/src/index.ts) + +```ts config-catalog +/** Activation skills and render bounds; lower deployment attachment limits take precedence. */ +export interface Config { + /** Skill names whose successful loading admits all three tools from the following step. */ + activationSkills: string[] + /** Maximum images one render returns, including worksheet fragments. */ + maxImages: number + /** Maximum pixels of each image. */ + maxImagePixels: number + /** Maximum width and height of each image in pixels. */ + maxImageDimension: number + /** Maximum PNG bytes across all images of one render. */ + maxImageBytes: number +} +``` + + @@ -4449,7 +4488,7 @@ export interface PresetSelectionConfig { ## `@deepseek-ai/dsh-tool-subagent-control` - `inject`: `tools` · `subagents` · `agents` · `systemPrompt` -- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:26`](../packages/subagent/tool-subagent-control/src/index.ts) +- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:24`](../packages/subagent/tool-subagent-control/src/index.ts) ```ts config-catalog /** Optional subagent control tools. */ @@ -4699,12 +4738,12 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts) +- `source`: [`packages/web/web-deepseek-api/src/index.ts:64`](../packages/web/web-deepseek-api/src/index.ts) ```ts config-catalog /** * Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or - * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek + * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, its `*Billing` chooses its DeepSeek * credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load * without error and have no effect. */ @@ -4719,6 +4758,14 @@ export interface Config { searchRequestsPerSecond: Volatile /** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */ sendOriginalQuestion: Volatile + /** + * Credential Search sends without {@link searchBaseURL} and {@link searchApiKeyEnv}. `auto` uses the conversation's + * DeepSeek account or API key while it chats on a DeepSeek model route, and otherwise the API key, then the signed-in + * account; `account` uses only the signed-in account; `api-key` uses only the API key. Defaults to `auto`. + */ + searchBilling: Volatile<'auto' | 'account' | 'api-key'> + /** Credential Fetch sends without {@link fetchBaseURL} and {@link fetchApiKeyEnv}, chosen as {@link searchBilling} is. */ + fetchBilling: Volatile<'auto' | 'account' | 'api-key'> /** * Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the * official base applies. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b478360483..4d4dde7b7a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -179,7 +179,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-team` - `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` -- `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) +- `source`: [`packages/team/agent-team/src/types.ts:136`](../packages/team/agent-team/src/types.ts) ```ts config-catalog /** Team-service defaults and deployment limits. */ @@ -963,8 +963,6 @@ export interface Config { mode: 'launch' | 'attach' /** CDP HTTP or WebSocket endpoint, required only for attach mode. */ cdpEndpoint?: string - /** Optional Stagehand extension id for an existing browser. */ - extensionId?: string /** Installed Chrome/Chromium executable used in launch mode. */ executablePath?: string /** Hide an owned browser's window. */ @@ -1233,7 +1231,7 @@ export interface InspectorOptions { readonly maxJournalBytes?: number /** Maximum active and completed fetch requests retained by the Worker. */ readonly maxRetainedRequests?: number - /** Maximum encoded bytes accepted in one source transport frame. */ + /** Maximum encoded bytes accepted in one source transport frame; values below 5121 leave no Cordis snapshot budget and fail. */ readonly maxSourceFrameBytes?: number /** Maximum observation records accepted in one source batch. */ readonly maxSourceRecordsPerFrame?: number @@ -2241,7 +2239,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable - - + + -## `@deepseek-ai/dsh-office-to-pdf` +## `@deepseek-ai/dsh-office-kit` -- `source`: [`packages/document/office-to-pdf/src/index.ts:31`](../packages/document/office-to-pdf/src/index.ts) +- `source`: [`packages/document/office-kit/src/index.ts:38`](../packages/document/office-kit/src/index.ts) ```ts config-catalog /** Provider concurrency and kit rendering/font configuration. */ @@ -2552,19 +2550,25 @@ export interface Config { maxSourceBytes: number /** Maximum concurrent background jobs; zero refuses speculative work. */ maxBackgroundConversions: number - /** Maximum retained content-addressed PDFs. */ + /** Maximum retained content-addressed outputs. */ maxCachedEntries: number - /** Maximum retained PDF bytes. */ + /** Maximum retained output bytes. */ maxCachedBytes: number /** Maximum retained source-version aliases to cached content. */ maxSourceEntries: number + /** Absolute directory for converted and recalculated files, with one subdirectory per Session. */ + outputDirectory: string + /** Milliseconds a converted or recalculated file is kept. */ + outputRetentionMs: number + /** Maximum bytes across retained converted and recalculated files; the oldest leave first. */ + maxOutputStoreBytes: number /** Conversion deadline in milliseconds; excludes the DSH queue. */ timeoutMs: number /** Maximum authorized source bytes. */ maxInputBytes: number - /** Maximum complete PDF bytes. */ + /** Maximum complete PDF, converted, or recalculated file bytes. */ maxOutputBytes: number - /** Exported raster-image DPI. */ + /** Image resolution in DPI of PDF exports; `renderImages()` takes its resolution from each request's `dpi`. */ maxImageResolution: number /** Maximum OOXML ZIP entries. */ maxArchiveEntries: number @@ -2582,7 +2586,7 @@ export interface Config { maxLoadedFontBytes: number } ``` - + @@ -2964,7 +2968,7 @@ export interface Config { - `inject`: `sessionProjections` - `refs`: [`SandboxMode`](subsystems/sandbox.zh.md) -- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:94`](../packages/sandbox/sandbox-policy/src/index.ts) +- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:95`](../packages/sandbox/sandbox-policy/src/index.ts) ```ts config-catalog /** @@ -2988,7 +2992,7 @@ export interface Config { * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under - * `read-only`. + * `read-only`. Windows process confinement ignores them; harness file tools honor them. */ additionalWritableRoots?: string[] } @@ -3352,7 +3356,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:30`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -3424,18 +3428,28 @@ export interface Config { ## `@deepseek-ai/dsh-skill-office` - `inject`: `skills` -- `source`: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts) +- `source`: [`packages/skill/skill-office/src/index.ts:34`](../packages/skill/skill-office/src/index.ts) ```ts config-catalog -/** Office skill resource location. */ +/** Configuration after schema defaults apply. */ +export interface ResolvedConfig extends Config { + /** Python environment named in the Python authoring and verification skills. */ + pythonEnvironment: PythonEnvironment +} + +/** Office skill resources and Python environment. */ export interface Config { /** Absolute assets directory containing skill folders; defaults to packaged assets. */ assetRoot?: string - /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ - node?: string - /** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */ - cli?: string | false + /** Python environment named in the Python authoring and verification skills. @default 'workspace-dependencies' */ + pythonEnvironment?: PythonEnvironment } + +/** + * Python source for Office scripts: `'workspace-dependencies'` directs the model to the bundled interpreter and libraries + * returned by `load_workspace_dependencies`; `'user'` directs it to an existing project or user Python. + */ +export type PythonEnvironment = 'workspace-dependencies' | 'user' ``` @@ -3628,7 +3642,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:181`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -4239,6 +4253,31 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-tool-office` + +- `inject`: `tools` · `fs` · `workingDirectory` · `agents` +- `source`: [`packages/document/tool-office/src/index.ts:38`](../packages/document/tool-office/src/index.ts) + +```ts config-catalog +/** Activation skills and render bounds; lower deployment attachment limits take precedence. */ +export interface Config { + /** Skill names whose successful loading admits all three tools from the following step. */ + activationSkills: string[] + /** Maximum images one render returns, including worksheet fragments. */ + maxImages: number + /** Maximum pixels of each image. */ + maxImagePixels: number + /** Maximum width and height of each image in pixels. */ + maxImageDimension: number + /** Maximum PNG bytes across all images of one render. */ + maxImageBytes: number +} +``` + + @@ -4451,7 +4490,7 @@ export interface PresetSelectionConfig { ## `@deepseek-ai/dsh-tool-subagent-control` - `inject`: `tools` · `subagents` · `agents` · `systemPrompt` -- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:26`](../packages/subagent/tool-subagent-control/src/index.ts) +- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:24`](../packages/subagent/tool-subagent-control/src/index.ts) ```ts config-catalog /** Optional subagent control tools. */ @@ -4701,12 +4740,12 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts) +- `source`: [`packages/web/web-deepseek-api/src/index.ts:64`](../packages/web/web-deepseek-api/src/index.ts) ```ts config-catalog /** * Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or - * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek + * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, its `*Billing` chooses its DeepSeek * credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load * without error and have no effect. */ @@ -4721,6 +4760,14 @@ export interface Config { searchRequestsPerSecond: Volatile /** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */ sendOriginalQuestion: Volatile + /** + * Credential Search sends without {@link searchBaseURL} and {@link searchApiKeyEnv}. `auto` uses the conversation's + * DeepSeek account or API key while it chats on a DeepSeek model route, and otherwise the API key, then the signed-in + * account; `account` uses only the signed-in account; `api-key` uses only the API key. Defaults to `auto`. + */ + searchBilling: Volatile<'auto' | 'account' | 'api-key'> + /** Credential Fetch sends without {@link fetchBaseURL} and {@link fetchApiKeyEnv}, chosen as {@link searchBilling} is. */ + fetchBilling: Volatile<'auto' | 'account' | 'api-key'> /** * Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the * official base applies. diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 28ee60cb3d..5f9ee81f66 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -6,8 +6,8 @@ en: b94bcdbebcd6355e zh: 9fd61c2b986da759 /cookbook-adding-a-workspace-package/1-create-the-package: - en: 062249fc249b2fec - zh: f6b9fe7f63c4cbad + en: 8dced23a19db2c69 + zh: ad9021f9fe5e9bb2 /cookbook-adding-a-workspace-package/2-register-it-in-the-root-configs: en: 12b985dee93a47a7 zh: 1027db3d251cd860 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 16fe3e564f..7c8b051817 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -24,7 +24,7 @@ packages/// Choose an existing group when one matches the package's role (`core`, `llm`, `shell`, `compaction`, `subagent`, `todo`, `session`, `client`/`host`, `util`, or `test-support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `@deepseek-ai/cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `@deepseek-ai/schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/types/**/*.d.ts`, and package-specific runtime artifacts recognized by the gate. A package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Do not publish `src`, declaration maps, JS maps, or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `@deepseek-ai/cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `@deepseek-ai/schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, `src`, and package-specific runtime artifacts recognized by the gate. A package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Declaration and JavaScript maps publish beside the files they describe, and `src` publishes with them, so a consumer editor and a plugin author read the implementation behind an exported declaration; do not publish stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 0d74f645dc..68cde8056a 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -24,7 +24,7 @@ packages/// 当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`shell`、`compaction`、`subagent`、`todo`、`session`、`client`/`host`、`util` 或 `test-support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。 -package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`@deepseek-ai/cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`@deepseek-ai/schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/types/**/*.d.ts` 以及门禁认可的包专用运行时产物。如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。不要发布 `src`、声明映射、JS map 或陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 +package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`@deepseek-ai/cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`@deepseek-ai/schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map`、`src` 以及门禁认可的包专用运行时产物。如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。声明映射与 JavaScript map 随各自描述的产物一起发布,`src` 也随之发布,让消费方编辑器和插件作者都能读到导出声明背后的实现;不要发布陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 70e290a43c..5f78bb6983 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -9,8 +9,8 @@ en: 2cc80a2a79e23005 zh: 87e43383055890ce /tool-authoring-reference/rules-of-the-execute-contract: - en: 0b85f46f6a5bd8af - zh: 56eb8d05198333dc + en: f12260d67c4c4d4d + zh: 3955d44622432fd9 /tool-authoring-reference/long-running-work: en: 5f6b0c15cb91e21b zh: 633a2192df186ac3 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index ebef4b6b03..b1eaed1098 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -29,6 +29,7 @@ export function apply(ctx: Context) { async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field + // A production tool converts errno failures such as ENOENT into FsError codes return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }, })) @@ -39,12 +40,12 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S ## Rules of the execute() contract -- **Args are validated for you.** `defineTool` validates model-generated `arguments` against the unified `ParameterSchemaSpec` before `execute` runs (types, required keys, literal constraints, exact-one unions, and nested values), so inside `execute` the args match `InferArgs`. Explicit object nodes declare `additionalProperties: true | false`; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules. Raw JSON-Schema tools registered directly own their input validation. +- **Args are validated for you.** `defineTool` validates model-generated `arguments` against the unified `ParameterSchemaSpec` before `execute` runs (types, required keys, literal constraints, exact-one unions, and nested values), so inside `execute` the args match `InferArgs`. Explicit object nodes declare `additionalProperties: true | false`; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules, and reject them with `ToolArgsError`. Raw JSON-Schema tools registered directly own their input validation. - **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, the required caller-owned `signal`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. Only an around-dispatch wrapper receives a mutable view, and it may replace and restore the required `exec.signal` to impose a deadline but cannot remove it. - **Declare and return one canonical JSON value.** `output.schema` uses `ValueSchemaSpec` and may have an object, array, scalar, or null root. `execute` returns only the inferred value; the registry snapshots it as lossless JSON, validates it, freezes it, and passes it to `output.render(args, value)`. Do not return content blocks from the body or make callers parse prose for ids and fields. -- **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. -- **Honor `exec.signal`.** Cancel in-flight work when it fires. +- **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw a `HarnessError` subclass with a stable `code` for every expected failure; a thrown `HarnessError` is the only tool-body outcome that keeps `error.name` and `error.code` on the result, and the [error-handling rules](../../.agents/skills/dsh-error-handling/SKILL.md#tool-failures) define the classes and codes. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. +- **Honor `exec.signal`.** Cancel in-flight work when it fires and throw a `HarnessError` named `AbortError` whose code is the `TOOL_ABORTED` constant (`ABORTED`), matching the registry's own cancellation result. - **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. - **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'my-plugin' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Declare that `kind` through [`MessageSourceMap`](../subsystems/llm-streaming.md#content-blocks-and-messages) in your plugin: session format V4 rejects the retired `{ kind: 'plugin', plugin: '' }` wrapper at message admission. Guard against disposed agents (try/catch). diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 28329e6382..0149575daf 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -29,6 +29,7 @@ export function apply(ctx: Context) { async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field + // A production tool converts errno failures such as ENOENT into FsError codes return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }, })) @@ -39,12 +40,12 @@ export function apply(ctx: Context) { ## execute() 约定的规则 -- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据统一的 `ParameterSchemaSpec` 校验模型生成的 `arguments`(类型、必填键、字面量约束、恰好匹配一个分支的联合以及嵌套值),因此 `execute` 内的 args 会匹配 `InferArgs`。显式对象节点必须声明 `additionalProperties: true | false`;隐式参数根对象保持开放。你仍需手动检查 schema DSL 无法表达的约束,例如非空字符串、正数或跨字段规则。直接注册的原始 JSON Schema 工具自行负责输入校验。 +- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据统一的 `ParameterSchemaSpec` 校验模型生成的 `arguments`(类型、必填键、字面量约束、恰好匹配一个分支的联合以及嵌套值),因此 `execute` 内的 args 会匹配 `InferArgs`。显式对象节点必须声明 `additionalProperties: true | false`;隐式参数根对象保持开放。你仍需手动检查 schema DSL 无法表达的约束,例如非空字符串、正数或跨字段规则,并以 `ToolArgsError` 拒绝。直接注册的原始 JSON Schema 工具自行负责输入校验。 - **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 - **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token`、必填且由调用方持有的 `signal`,以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。只有 around-dispatch 包装器会收到可变视图;它可以替换并恢复必填的 `exec.signal` 以施加截止时间,但不能移除该信号。 - **声明并返回一个规范 JSON 值。** `output.schema` 使用 `ValueSchemaSpec`,根可以是对象、数组、标量或 null。`execute` 只返回推导出的值;注册表将其快照为无损 JSON,完成校验和冻结后,再传给 `output.render(args, value)`。工具主体不要返回内容块,也不要迫使调用方从自然语言中解析 id 和字段。 -- **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 -- **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 +- **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。每种预期失败都应抛出带稳定 `code` 的 `HarnessError` 子类;在工具主体的各种产出中,只有抛出的 `HarnessError` 会让结果保留 `error.name` 和 `error.code`,具体的错误类和 code 由[错误处理规则](../../.agents/skills/dsh-error-handling/SKILL.md#tool-failures)规定。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 +- **遵守 `exec.signal`。** 信号触发时取消进行中的工作,并抛出 `name` 为 `AbortError`、code 为 `TOOL_ABORTED` 常量(值为 `ABORTED`)的 `HarnessError`,与注册表自己生成的取消结果一致。 - **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 - **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'my-plugin' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。在插件里通过 [`MessageSourceMap`](../subsystems/llm-streaming.zh.md#content-blocks-and-messages) 声明该 `kind`:session format V4 在消息准入处拒绝退役的 `{ kind: 'plugin', plugin: '' }` 包装。请防范已 dispose 的 agent(try/catch)。 diff --git a/docs/defensive-patterns/conversation-and-session.i18n.yaml b/docs/defensive-patterns/conversation-and-session.i18n.yaml index 210cfe8b06..d8d0fcc6ae 100644 --- a/docs/defensive-patterns/conversation-and-session.i18n.yaml +++ b/docs/defensive-patterns/conversation-and-session.i18n.yaml @@ -6,8 +6,8 @@ en: 499609ba1c6822d8 zh: 7429eabcce7d7c14 /conversation-and-session-rules/projection-is-the-only-read-and-compute-path: - en: 9fa9a332c93ba386 - zh: ce01ccb47dfa5df7 + en: 99cd4d8469857945 + zh: 57dd7d1137ddde80 /conversation-and-session-rules/cold-reads-are-the-last-resort: en: 32991da738e47f7f zh: b138910f8c16d103 diff --git a/docs/defensive-patterns/conversation-and-session.md b/docs/defensive-patterns/conversation-and-session.md index f2f610ebe4..c8fe7af265 100644 --- a/docs/defensive-patterns/conversation-and-session.md +++ b/docs/defensive-patterns/conversation-and-session.md @@ -10,7 +10,7 @@ Session data is read and computed through Projection. A client reads a projectio - Judged condition: every Session-derived value a client renders arrives through a projection read at the render position, and every new in-Session computation is registered as a unit whose result reaches clients as a published value. A component that fetches Session data itself, or a computation a reader triggers on demand, is the violation. - State and View: a unit folds host-side State and declares the client-visible View over it, and the registration states that division. The `ProjectionDefinition` fields and the registry that drives them belong to [session-projection.md](../subsystems/session-projection.md#ctxsessionprojections--sessionprojectionregistry). -- Version: `stateVersion` starts at 1 and is bumped whenever the serialized state fields or the fold semantics change, compatible or not. Prefer a compatible change where one exists, but still bump; registering one key twice with different `stateVersion` values throws instead of merging. +- Version: `stateVersion` starts at 1. Follow the [checkpoint version rules](../../packages/session/session-projection/README.md): state fields or fold changes require a bump, except for tested optional additions that preserve historical meaning and old-checkpoint suffix equivalence with full replay. Registering one key twice with different versions throws instead of merging. - Compute strategy: units compute in full by default. Narrowing what a unit computes needs the owning domain expert's confirmation, never the implementer's own decision. - Boundary: a partial projection view may serve interface availability for individual keys, such as a cached block drawn before the connected Session answers. It never decides an externally visible result, which waits for the standard projection value. diff --git a/docs/defensive-patterns/conversation-and-session.zh.md b/docs/defensive-patterns/conversation-and-session.zh.md index 448ce061ec..b1281c6b5d 100644 --- a/docs/defensive-patterns/conversation-and-session.zh.md +++ b/docs/defensive-patterns/conversation-and-session.zh.md @@ -10,7 +10,7 @@ Session 数据一律经 Projection 读取与计算。客户端读投影值,而 - 判定条件:客户端渲染的每个 Session 派生值都在渲染位置经投影读取取得,每个新增的会话内计算都注册为单元、其结果以已发布值的形式到达客户端。组件自己拉取 Session 数据、或读者按需触发一次计算,都属违规。 - State 与 View:单元折叠 host 侧 State,并在其上声明客户端可见的 View,二者分工写在注册里。`ProjectionDefinition` 的字段与驱动它们的注册表归 [session-projection.zh.md](../subsystems/session-projection.zh.md#ctxsessionprojections--sessionprojectionregistry)。 -- 版本:`stateVersion` 从 1 起,只要序列化 state 字段或折叠语义变化就自增,兼容与否都一样。存在兼容改法时优先兼容,但版本仍要自增;同一 key 以不同 `stateVersion` 注册两次会抛错,而不是合并。 +- 版本:`stateVersion` 从 1 起。遵循[检查点版本规则](../../packages/session/session-projection/README.zh.md):状态字段或折叠变化需要递增版本,但经过测试、保留历史含义且旧检查点加后缀恢复与全量回放一致的可选字段扩展除外。同一 key 以不同版本注册两次会抛错,而不是合并。 - 计算策略:单元默认全量计算。收窄某个单元的计算范围需要对应领域专家确认,绝不能由实现者自行决定。 - 边界:非完全 projection 渲染可用于某些 key 的界面可用性展示,例如在连接中的 Session 应答之前先画缓存块。它绝不参与对外可见结果的判定,那些结果等标准 projection 值。 diff --git a/docs/defensive-patterns/package-surface.i18n.yaml b/docs/defensive-patterns/package-surface.i18n.yaml index 6b216a0443..5a3fb98e69 100644 --- a/docs/defensive-patterns/package-surface.i18n.yaml +++ b/docs/defensive-patterns/package-surface.i18n.yaml @@ -18,8 +18,8 @@ en: e68bfcde70f845b4 zh: 73e5c4fb067c4fa1 /package-surface-rules/internal-dependency-syntax-follows-the-package-family: - en: d6bb855545b7b05b - zh: 6c489ed5a2ed0ae5 + en: 0b3c0da2bfcea15c + zh: 133b381f64c42d1f /package-surface-rules/a-new-resolve-hook-is-refused-outright: en: b420ae3e228432d5 zh: 0c49c4a9c3a5262c diff --git a/docs/defensive-patterns/package-surface.md b/docs/defensive-patterns/package-surface.md index 5876dfa463..8efe249f95 100644 --- a/docs/defensive-patterns/package-surface.md +++ b/docs/defensive-patterns/package-surface.md @@ -151,7 +151,7 @@ Restating a required-devDependency package list or the field rules on this page Internal dependency syntax follows the package family: `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` use `workspace:*`, every other internal package uses `workspace:~`, and `workspace:^` has retired. The same dependency name is written the same way in every workspace, and a mismatch is a hard scan failure rather than a warning a reviewer can waive. The uniform syntax keeps one resolution path and prevents duplicate instances of a dependency and the resolution differences that follow them. - Judged by `workspaceRange()` in [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts): the `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` families get `workspace:*`, every other internal package gets `workspace:~`; the scan walks the managed manifests and compares every declaration of the same dependency name. -- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `^0.1.5` in `apps/desktop-host/package.json` (dependencies), `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-to-pdf/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies). +- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `0.2.3` in `packages/bundle/web-app/package.json` (devDependencies) and `packages/document/office-kit/package.json` (dependencies). - Distribution is not restated here; the recompute command below prints the current counts of `workspace:*`, `workspace:~`, `workspace:^`, and any exact-range declaration outside the family rule. - Exception: the scan decides mechanically; a genuine exception means changing the scan and the policy file with the domain owner's confirmation and the reason on the change. - Recompute: @@ -167,7 +167,7 @@ Each family in its form, with the non-member exception left as an ordinary versi "dependencies": { "@deepseek-ai/dsh-base": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/libreoffice-kit": "^0.1.5" + "@deepseek-ai/libreoffice-kit": "0.2.3" }, "devDependencies": { "@deepseek-ai/dsh-typert-registry": "workspace:*" diff --git a/docs/defensive-patterns/package-surface.zh.md b/docs/defensive-patterns/package-surface.zh.md index 8452120e4c..2c7bc516b2 100644 --- a/docs/defensive-patterns/package-surface.zh.md +++ b/docs/defensive-patterns/package-surface.zh.md @@ -151,7 +151,7 @@ Restating a required-devDependency package list or the field rules on this page 内部依赖写法按包族决定:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 用 `workspace:*`,其余内部包用 `workspace:~`,`workspace:^` 已退出。同一个依赖名在所有 workspace 内写法一致,不一致是扫描硬失败,不是评审可以放行的警告。写法统一才能保持一条解析路径,避免同一个依赖出现重复实例以及随之而来的解析差异。 - 判定在 [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts) 的 `workspaceRange()`:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 包族取 `workspace:*`,其余内部包取 `workspace:~`;扫描遍历受管清单,比对同一依赖名的每一处声明。 -- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `apps/desktop-host/package.json`(dependencies)、`packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-to-pdf/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `^0.1.5`。 +- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `packages/bundle/web-app/package.json`(devDependencies)和 `packages/document/office-kit/package.json`(dependencies)中声明为 `0.2.3`。 - 分布不在此复述;下面的复算命令会打印 `workspace:*`、`workspace:~`、`workspace:^` 的当前数量,以及族规则之外的精确版本声明。 - 例外:门禁机械判定;确需例外意味着修改扫描与策略文件,并须经对应领域专家确认、在改动上说明理由。 - 复算: @@ -167,7 +167,7 @@ python3 -c "import json,glob,collections; c=collections.Counter(); [c.update([r] "dependencies": { "@deepseek-ai/dsh-base": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/libreoffice-kit": "^0.1.5" + "@deepseek-ai/libreoffice-kit": "0.2.3" }, "devDependencies": { "@deepseek-ai/dsh-typert-registry": "workspace:*" diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d344748ffd..99ba79208d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -14,7 +14,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `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:399`](../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:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`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), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../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:328`](../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) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | @@ -23,11 +23,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../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:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`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:417`](../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:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:705`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:685`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:712`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../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`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -59,28 +59,28 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `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) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:76`](../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) | | `mcp-manager/status` | `parallel` | [`packages/mcp/mcp-manager/src/types.ts:46`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`parallel`) | - | | `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:432`](../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:438`](../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:445`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:424`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../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` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../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:458`](../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:465`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:444`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:472`](../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:174`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:634`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `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:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, [`office-kit`](../packages/document/office-kit), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-office`](../packages/document/tool-office), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | @@ -88,7 +88,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index cee3960282..54938cd97b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -16,7 +16,7 @@ | `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:399`](../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:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`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), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../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:328`](../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) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | @@ -25,11 +25,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../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:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`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:417`](../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:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:705`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:685`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:712`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../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`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -61,28 +61,28 @@ | `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `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) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:76`](../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) | | `mcp-manager/status` | `parallel` | [`packages/mcp/mcp-manager/src/types.ts:46`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`parallel`) | - | | `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:432`](../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:438`](../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:445`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:424`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../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` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../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:458`](../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:465`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:444`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:472`](../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:174`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:634`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `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:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, [`office-kit`](../packages/document/office-kit), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-office`](../packages/document/tool-office), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | @@ -90,7 +90,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | diff --git a/docs/module-graph.md b/docs/module-graph.md index f58da15929..e5f11c68b4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -252,7 +252,8 @@ flowchart TD pkg_workspace_changes["workspace-changes"] end subgraph group_document["packages/document"] - pkg_office_to_pdf["office-to-pdf"] + pkg_office_kit["office-kit"] + pkg_tool_office["tool-office"] end subgraph group_experimental["packages/experimental"] pkg_action_cards["action-cards"] @@ -1402,6 +1403,15 @@ flowchart TD pkg_session_reference --> pkg_subagent pkg_session_reference --> pkg_system_prompt pkg_session_reference --> pkg_typert_protocol + pkg_tool_office --> pkg_agent + pkg_tool_office --> pkg_attachment + pkg_tool_office --> pkg_fs + pkg_tool_office --> pkg_llm + pkg_tool_office --> pkg_office_kit + pkg_tool_office --> pkg_skill + pkg_tool_office --> pkg_subagent + pkg_tool_office --> pkg_tools + pkg_tool_office --> pkg_working_directory pkg_experimental_subagent_oar --> pkg_llm pkg_experimental_subagent_oar --> pkg_session pkg_experimental_subagent_oar --> pkg_subagent @@ -1619,7 +1629,7 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | -| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`office-kit`](../packages/document/office-kit) | `document` | — | | [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | | [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | @@ -1871,6 +1881,7 @@ flowchart TD | [`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) | +| [`tool-office`](../packages/document/tool-office) | `document` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`office-kit`](../packages/document/office-kit), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`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), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 372f734770..62a0ff3872 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -254,7 +254,8 @@ flowchart TD pkg_workspace_changes["workspace-changes"] end subgraph group_document["packages/document"] - pkg_office_to_pdf["office-to-pdf"] + pkg_office_kit["office-kit"] + pkg_tool_office["tool-office"] end subgraph group_experimental["packages/experimental"] pkg_action_cards["action-cards"] @@ -1404,6 +1405,15 @@ flowchart TD pkg_session_reference --> pkg_subagent pkg_session_reference --> pkg_system_prompt pkg_session_reference --> pkg_typert_protocol + pkg_tool_office --> pkg_agent + pkg_tool_office --> pkg_attachment + pkg_tool_office --> pkg_fs + pkg_tool_office --> pkg_llm + pkg_tool_office --> pkg_office_kit + pkg_tool_office --> pkg_skill + pkg_tool_office --> pkg_subagent + pkg_tool_office --> pkg_tools + pkg_tool_office --> pkg_working_directory pkg_experimental_subagent_oar --> pkg_llm pkg_experimental_subagent_oar --> pkg_session pkg_experimental_subagent_oar --> pkg_subagent @@ -1621,7 +1631,7 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | -| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`office-kit`](../packages/document/office-kit) | `document` | — | | [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | | [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | @@ -1873,6 +1883,7 @@ flowchart TD | [`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) | +| [`tool-office`](../packages/document/tool-office) | `document` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`office-kit`](../packages/document/office-kit), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`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), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 338dcc1299..310e193d05 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: d334560286daab57 - zh: ddece2494dd2131c + en: 35ffb6107a5fbae2 + zh: 06ce2128075e157b /session-persistence-event-catalog/event-envelope: en: 56414ffd00cdc32a zh: 6dc11eb013e95e41 @@ -207,8 +207,8 @@ en: 9630752e97ddbfbc zh: 9630752e97ddbfbc /session-persistence-event-catalog/events/subagent/subagent-catalog-log-only: - en: e6ce4b5a98ff2f52 - zh: 9a8e112754679aef + en: cc33ef2805abbfdd + zh: d35696cb06672d6d /session-persistence-event-catalog/events/subagent/subagent-descriptor-log-only: en: f5d53639dc5afdfc zh: 8c553e7397d08832 @@ -225,20 +225,20 @@ en: a640800a38b3c874 zh: a640800a38b3c874 /session-persistence-event-catalog/events/team/team-member-log-only: - en: b52b572664da0f8e - zh: 1c8fd9d1634ec6e1 + en: 4d5eb0eb3d448f62 + zh: b682e215ae875517 /session-persistence-event-catalog/events/team/team-message-delivered-log-only: - en: 6c9a217924277caa - zh: 417f47f16980f494 + en: ae3f548b1bbc6ae8 + zh: 1fc8b5db4d10154f /session-persistence-event-catalog/events/team/team-message-queued-log-only: - en: 1cf7f8d33840ab23 - zh: 6b52612308519ddc + en: df5f8e4a849e013e + zh: de97631b57b1a8ea /session-persistence-event-catalog/events/team/team-mode-log-only: - en: a3d97b5c076edb80 - zh: b45b64d87c16479a + en: 4ed3c462b2d84149 + zh: d1adcd5c4a806abe /session-persistence-event-catalog/events/team/team-task-log-only: - en: 36e28e9681653ab9 - zh: 3db7addbba6899e4 + en: f66ac682045629e7 + zh: 52bfb8555b67dd52 /session-persistence-event-catalog/events/todo: en: 308d4bb4b6b96e35 zh: 308d4bb4b6b96e35 @@ -717,8 +717,11 @@ en: ade80ed58d3b63b2 zh: ade80ed58d3b63b2 /session-persistence-event-catalog/resolved-persistence-types/resume: - en: e27772872766bd6d - zh: e27772872766bd6d + en: f9a34b26963fc22d + zh: f9a34b26963fc22d +/session-persistence-event-catalog/resolved-persistence-types/retry: + en: 68c90ebe83c80004 + zh: 68c90ebe83c80004 /session-persistence-event-catalog/resolved-persistence-types/runtime-context: en: 784f0d4afdaaa472 zh: 784f0d4afdaaa472 @@ -963,8 +966,11 @@ en: 6884d3e0e7f31048 zh: 6884d3e0e7f31048 /session-persistence-event-catalog/resolved-persistence-types/2: - en: d462b9a5e0855776 - zh: d462b9a5e0855776 + en: ca872ec805526f3d + zh: ca872ec805526f3d +/session-persistence-event-catalog/resolved-persistence-types/3: + en: 27a98df839b87cc8 + zh: 27a98df839b87cc8 /session-persistence-event-catalog/resolved-persistence-types/4: en: 9ec6b8bd755f3e33 zh: 9ec6b8bd755f3e33 @@ -1020,11 +1026,11 @@ en: a6a2e38fdd6d127a zh: 1fe82356d89f5769 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: 054dbd6367e1403d - zh: 5b071d10ae1ec245 + en: 8cc704c4048167a7 + zh: d27823620065ad25 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: 58bc64eb0f7fb589 - zh: 8cc8279eb5786ef5 + en: b91fd6f924b6d84e + zh: c74abb179c889ed6 /session-persistence-event-catalog/resolved-persistence-types/epochheader: en: 3bdc8ecd0bdf0c9c zh: 0fe5b75c174ebab4 @@ -1134,11 +1140,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: a3754a186db0e1bb - zh: e9467b50a27889d5 + en: fdd00b0eb42e499d + zh: a6dc05d9a0a8f39e /session-persistence-event-catalog/resolved-persistence-types/message: - en: 0be581a4095c3493 - zh: 2c815426f3d3717a + en: 0a728ea7fb7a297e + zh: 9539ccd702170fa6 /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1149,14 +1155,14 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 702995c31653ab91 - zh: cf60875749663bd3 + en: 1918aad92fd51fc9 + zh: c36696f1b77baa7d /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: 1ec5fd7b1b8050a9 - zh: ee4a3f61be48cd17 + en: 80d51b79557bc4d8 + zh: e7a9721b8b3ea9f3 /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: 9e25f2f1513f7b15 - zh: 2723b6aa3d8b723e + en: 7106ebd39f6cd290 + zh: 4e36b5377d222a0a /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 5f9154ca34235ab9 zh: a833105910ebfa82 @@ -1215,11 +1221,11 @@ en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: a537c3568f3f07db - zh: 20c97f3c3fd82e8b + en: 2cf3dc8a82d4d4be + zh: 638e945c5007d6f4 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: efa083dfdcf04208 - zh: eed525395ff9f2e8 + en: b45c7e8370d7be1e + zh: 252fdc0ff2b9338c /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1239,11 +1245,11 @@ en: ecc15ebadb77afdf zh: 97ac27d6a4175f6f /session-persistence-event-catalog/resolved-persistence-types/streamchunk: - en: 76ef1d6de369c7b5 - zh: e033b6bbaa455e87 + en: 747e384b3176e0cb + zh: defbff408ea7c97c /session-persistence-event-catalog/resolved-persistence-types/subagentcatalogevent: - en: 725539dcd5c335ed - zh: 5ca08d3667f84ff9 + en: 8a56ae8e701509b3 + zh: caef4f0223f65470 /session-persistence-event-catalog/resolved-persistence-types/subagentdescriptordata: en: 2b9c68682eec6f78 zh: 8ef5e64326c1eae6 @@ -1266,14 +1272,14 @@ en: 1815ad87650025e6 zh: d2a0dceabcc2090e /session-persistence-event-catalog/resolved-persistence-types/teammessagesnapshot: - en: d7ba2fe5b24c90cb - zh: b26f7e1ef0eccce0 + en: de9050877571f27c + zh: 10569f17e12941df /session-persistence-event-catalog/resolved-persistence-types/teammessagesource: - en: 2ca6c43fc056fd37 - zh: 3d5b5f2917f326fc + en: 38db960ffd1f3ea5 + zh: ff46100e3b8f2755 /session-persistence-event-catalog/resolved-persistence-types/teamtasksnapshot: - en: 0c6aac89e3dcb25c - zh: d772017667ffc2a1 + en: f0b7c785ccae286a + zh: 5bf796897317dca0 /session-persistence-event-catalog/resolved-persistence-types/teamtaskstatus: en: b6bd8e9986773217 zh: ed177c2f090964af @@ -1329,14 +1335,14 @@ en: 778debd61e0284a2 zh: 2cc82ad9e80f18b1 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: bcd450bc3ade2a1a - zh: e442be61e4391383 + en: 50736659a0e79990 + zh: 34ebfd42a70069e3 /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: 6f29a4e108379f9a - zh: 2bf0f494b8fcd2b2 + en: 92cc51a395ca7c7b + zh: 4bd6029ea1241e25 /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: e4fc10ce549b51ad - zh: cb29ffcb8003b355 + en: f36c65420460fcf1 + zh: 4208e6734c0672df /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1362,8 +1368,8 @@ en: 4147b170f52814af zh: e78991ac1c41ebec /session-persistence-event-catalog/resolved-persistence-types/string: - en: caa52cd38cec3aee - zh: 3ff0f1fceaac2eb1 + en: ef89e74973f4a575 + zh: 0bb1564a0418501c /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1446,21 +1452,27 @@ en: de1d59e9edd58a7d zh: 0e29b8e80db664dc /session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode: - en: 2ef977cb4a83bb0a - zh: 893fded259888650 + en: c3690b3beea61c52 + zh: 7a788be5921e8d8f /session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~2: + en: 2af7543c81f83558 + zh: a96b05e9c12d0e7b +/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~3: en: 669e284c656449c7 zh: e40ac8cb904bd983 -/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~3: +/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~4: en: 1dc667423caefb33 zh: bd38fa2473673d14 -/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~4: - en: dc8738014c719f4e - zh: c773fd60c0090231 /session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~5: + en: 9599091956b57334 + zh: 54dbf4a35ec3ed35 +/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~6: + en: b1cdfd349b457908 + zh: d5d75fb74a5cefc8 +/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~7: en: 43cf5cd666b591a0 zh: 7c7a3011f8681f12 -/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~6: +/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~8: en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: @@ -1506,8 +1518,8 @@ en: c6fbe4268a4430db zh: 8fec9d6def3c775c /session-persistence-event-catalog/resolved-persistence-types/enabled: - en: 413aaf647388ee21 - zh: f36d0158e587bab3 + en: cc0470d67a4b8745 + zh: b7d54c9b9a89feef /session-persistence-event-catalog/resolved-persistence-types/end-start: en: 4b7a8793663d3fef zh: 249459c825d330c3 @@ -1527,11 +1539,11 @@ en: 4cb1c48095df98dc zh: 5a03e44761af559e /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: c21561adcf9398a5 - zh: fac8b21392750aae + en: 20d5dc748cb93377 + zh: ede516e3b59313a7 /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 081d257a87d8fb2d - zh: 49bdba3da8d5f7e4 + en: c4ea1e643651b5c1 + zh: 8f985997e5a942ec /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1548,11 +1560,11 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: 7c2c00c4635970ca - zh: 19818a2c43b4c57d + en: 55a056f4b970329f + zh: 6763a01bb6abe753 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 67b7eda689a3a115 - zh: 5ffdf39f3ee061b2 + en: d2b118f32769b0d9 + zh: edea493977131d2b /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: 8e86cd838e3e4a49 zh: f5fec2a8adf1f3cc @@ -1746,8 +1758,11 @@ en: 1f6dbdbf5d923df0 zh: aea576e5ec34c0fc /session-persistence-event-catalog/resolved-persistence-types/kind-repeat-tool-reminder-form-snapshot: - en: e9c88cceef2d8432 - zh: 14fb0897543b6960 + en: f58bccca3df9c2ad + zh: 5c92b6edeb7ce020 +/session-persistence-event-catalog/resolved-persistence-types/kind-retry: + en: c628d0575f81407a + zh: d9a70ce1e05dcce8 /session-persistence-event-catalog/resolved-persistence-types/kind-runtime-context: en: 68b5598e21cf8ba2 zh: 95ef1685af64a5f4 @@ -1884,8 +1899,8 @@ en: 4e102391d685eff5 zh: 9762243a13014f3d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: eaecebdbcd5cc4e4 - zh: f519d7ade666f48a + en: 808d28208287e843 + zh: 18a8d63978c984a7 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1920,17 +1935,17 @@ en: 3216b23c131d0070 zh: 73ca171c6014f16e /session-persistence-event-catalog/resolved-persistence-types/member-teamid-version: - en: d363c9c9b9989694 - zh: 67b0745c05540989 + en: 33c28b8c72b73604 + zh: 8b606ec7196176c8 /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: en: 86fe91fe83eeb08a zh: 2ebf2f1904829753 /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: - en: 4a479e13089129e3 - zh: 657d71530c8c36c3 + en: 6eab6fceb441352e + zh: 353625684fd63022 /session-persistence-event-catalog/resolved-persistence-types/messageid-targetid-teamid-version: - en: bdbf80439923b458 - zh: 624ee595a4bd994a + en: 39b24e7c4c5d21dc + zh: 33b0c2e6b76979f2 /session-persistence-event-catalog/resolved-persistence-types/mode-source: en: 3028b222670ce5ad zh: cae4c9437e560d61 @@ -1959,11 +1974,11 @@ en: 8f9e774f89087efa zh: 72474e7e556e2469 /session-persistence-event-catalog/resolved-persistence-types/targets: - en: 5360af614fe753e3 - zh: 96427c489cc71de6 + en: 65018960b97dbaab + zh: fb48ef7cb33b68aa /session-persistence-event-catalog/resolved-persistence-types/task-teamid-version: - en: 0922cbe4e7059d58 - zh: 9a7840f818d0a746 + en: b9b9931c83006ac1 + zh: 3fdb819fe976c292 /session-persistence-event-catalog/resolved-persistence-types/todos: en: 8ab873d30e062fd5 zh: a138832c6e299ebb @@ -1971,11 +1986,11 @@ en: 374b8adee8d571fa zh: b21057efc58e6490 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: 2ce26699bb57cf69 - zh: a5fc9ea510a3d458 + en: b85970209e42ae80 + zh: 83041799d5da5d74 /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: ae0b65d2268c758b - zh: 8244a6d65cfb01f3 + en: 1c6e8cf2fa5408c4 + zh: 14d24d858024e8cf /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -2019,11 +2034,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: f5c7b6c1d3610861 - zh: 2baec8a404127ce8 + en: 4b644c52aeab2ca7 + zh: 9f28958d7882f403 /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: fe3390ea4e6769e3 - zh: 5a25cbdf990ef85f + en: 6b632a8f70a45496 + zh: ad6c793e41731fd7 /session-persistence-event-catalog/resolved-persistence-types/type-disabled: en: 585627a50ece4e5f zh: 157837aee3da0ac3 @@ -2097,20 +2112,20 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: d26d1cd97d87f4a6 - zh: 992f1290578da1f4 + en: 65cf6985bf6b1e6d + zh: 9a18aff2bea3b213 /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: 211b5793ff00bed6 - zh: e8382b4f9adb08fd + en: 3c14dc1ac427e847 + zh: b0cacc9b61b1209b /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 /session-persistence-event-catalog/resolved-persistence-types/type-step-start: - en: bb27281e17bb5e4f - zh: b53e3fc8d916118b + en: 6ba6d118d44365e2 + zh: 846ea149908f0e04 /session-persistence-event-catalog/resolved-persistence-types/type-subagent-catalog: - en: 0f697ddcde1625dd - zh: 0dd87d2e108c5312 + en: b5dfa07e0b8d9f6a + zh: e93efb23da447ade /session-persistence-event-catalog/resolved-persistence-types/type-subagent-descriptor: en: 2adab4361a5b558d zh: 7f2a8da669f9e117 @@ -2130,11 +2145,11 @@ en: 735e6acfb6180948 zh: de7626c523ae57a2 /session-persistence-event-catalog/resolved-persistence-types/type-team-mode: - en: 0b50dd6b99450d8e - zh: a9f8d2b85e2092af + en: a6dc37a67e9f270e + zh: cc862392919c8cf7 /session-persistence-event-catalog/resolved-persistence-types/type-team-task: - en: 872aad5b748d70d5 - zh: 396f986a703b76fd + en: 722aa2e9b1ce6425 + zh: b18eb3b53d619e85 /session-persistence-event-catalog/resolved-persistence-types/type-text-chunks: en: 1484738718de5312 zh: 3e6fbe9c64068225 @@ -2181,11 +2196,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: 1f43a7944fa86bcf - zh: faa883c372923440 + en: 5c4bf682faecace1 + zh: e2014c3dc10bae79 /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: 28e1108b02b392dc - zh: 0dde507cef7d790e + en: fd9f5dacef8c0ce8 + zh: 79eab747b55dfaac /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: 99c8837344178a47 zh: bf746f5678346596 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f4c69dd93e..d3a8d5bb2e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482) | +| `event:agent/inbox/spliced` | event | `1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | [`{ type: "developer/message" }`](#persistence-type-sha256-70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a) | +| `event:developer/message` | event | `510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99` | [`{ type: "developer/message" }`](#persistence-type-sha256-510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -58,10 +58,10 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca) | +| `event:session/title-llm-request` | event | `3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | +| `event:subagent/catalog` | event | `d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -69,7 +69,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:team/message/delivered` | event | `48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb` | [`{ type: "team/message/delivered" }`](#persistence-type-sha256-48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb) | | `event:team/message/queued` | event | `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | [`{ type: "team/message/queued" }`](#persistence-type-sha256-21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d) | | `event:team/mode` | event | `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | [`{ type: "team/mode" }`](#persistence-type-sha256-920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75) | -| `event:team/task` | event | `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` | [`{ type: "team/task" }`](#persistence-type-sha256-d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a) | +| `event:team/task` | event | `bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7` | [`{ type: "team/task" }`](#persistence-type-sha256-bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7) | | `event:todo/write` | event | `b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26` | [`{ type: "todo/write" }`](#persistence-type-sha256-b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26) | | `event:tool-workflow/agent-end` | event | `babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7` | [`{ type: "tool-workflow/agent-end" }`](#persistence-type-sha256-babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7) | | `event:tool-workflow/agent-start` | event | `5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627` | [`{ type: "tool-workflow/agent-start" }`](#persistence-type-sha256-5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627) | @@ -81,7 +81,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | [`{ type: "user/message" }`](#persistence-type-sha256-dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450) | +| `event:user/message` | event | `50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97` | [`{ type: "user/message" }`](#persistence-type-sha256-50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -938,7 +938,7 @@ Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:44`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:52`](../packages/subagent/subagent/src/catalog.ts) @@ -1015,7 +1015,7 @@ Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) @@ -1033,7 +1033,7 @@ Source: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-tea Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:185`](../packages/team/agent-team/src/types.ts) @@ -1046,7 +1046,7 @@ Source: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-tea Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:183`](../packages/team/agent-team/src/types.ts) @@ -1057,7 +1057,7 @@ Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-tea 'team/mode': { enabled: boolean } ``` -Source: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) @@ -1070,7 +1070,7 @@ Source: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-tea Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -2446,6 +2446,14 @@ SHA-256: `34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66` `"resume"` + + +### `"retry"` + +SHA-256: `63ba8895cc09e4693cf0819fdcd18c084bef195febda5b6a23af279b3852be36` + +`"retry"` + ### `"runtime-context"` @@ -3102,6 +3110,14 @@ SHA-256: `a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80` `2` + + +### `3` + +SHA-256: `137ac60e26bcb192210d9a32a704b73b0924ed34019323defa1b787be95bd45e` + +`3` + ### `4` @@ -3410,7 +3426,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | required | `string` | - + @@ -3418,7 +3434,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796` +SHA-256: `824b1d4d4f007bdaa5eee7994a5db3ede7707c6cd2bf8e2509be176c7609e3bc` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3427,7 +3443,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-dca3ba43188095fa7342e49e6c206d4685125497d4767595bf12a62ea2fd73ce) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4126,7 +4142,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -4134,15 +4150,15 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39` +SHA-256: `c51f0d0a405bb96fcb008c0dc560a512dcd8c3919b6b446937f449ea2f7d21d8` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) -- [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) +- [`DeveloperMessage`](#persistence-type-sha256-824b1d4d4f007bdaa5eee7994a5db3ede7707c6cd2bf8e2509be176c7609e3bc) +- [`UserMessage`](#persistence-type-sha256-74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4219,7 +4235,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4227,7 +4243,7 @@ One of: ### `MessageSource` -SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` +SHA-256: `dca3ba43188095fa7342e49e6c206d4685125497d4767595bf12a62ea2fd73ce` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4289,6 +4305,7 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `repeat-tool-reminder` | `"relay"` | none | [`{ kind: "repeat-tool-reminder", form: "relay" }`](#persistence-type-sha256-4991dfc415c9efebc36fa6969e1798c7d2e42396cc1e6e0ac7d7c89c5c5c4aea) | | `repeat-tool-reminder` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "repeat-tool-reminder", form: "snapshot" }`](#persistence-type-sha256-db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c) | | `repeat-tool-reminder` | not declared | none | [`{ kind: "repeat-tool-reminder" }`](#persistence-type-sha256-b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1) | +| `retry` | not declared | `rpcId`: `string` | [`{ kind: "retry" }`](#persistence-type-sha256-8f1062573ea9184129dfdec43df1e8136eb5808746fd809bf6b539f3fd11362e) | | `runtime-context` | `"catalog"` | none | [`{ kind: "runtime-context", form: "catalog" }`](#persistence-type-sha256-58015da1c1950a66daf66ae24913d6a1fc6d1d9ac441b155f8847f7f8584ba8e) | | `runtime-context` | `"instructions"` | none | [`{ kind: "runtime-context", form: "instructions" }`](#persistence-type-sha256-0eeb17e1df89b1ebc2902116fe7145e0d321ef0db26e6bdebc802cb8513a01c1) | | `runtime-context` | `"notice"` | `summary`: `string` | [`{ kind: "runtime-context", form: "notice" }`](#persistence-type-sha256-cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc) | @@ -4352,13 +4369,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70` +SHA-256: `42e2ff61928fb7dfaf0f057d61686f3a32ff713da419d12d4f250efc96733fe0` -Array of [`Message`](#persistence-type-sha256-e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39). +Array of [`Message`](#persistence-type-sha256-c51f0d0a405bb96fcb008c0dc560a512dcd8c3919b6b446937f449ea2f7d21d8). @@ -4726,7 +4743,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4734,7 +4751,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25` +SHA-256: `bec5ca3a376f56f3d69df7f12a48e1cc07d4ea8305968773394c8734799faeb4` Sources: [`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) @@ -4742,7 +4759,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sess |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-42e2ff61928fb7dfaf0f057d61686f3a32ff713da419d12d4f250efc96733fe0) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4873,7 +4890,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4881,18 +4898,20 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87` +SHA-256: `058fc9a6b4fbd5d43437c3a64170002070dfc98e18d87de294b564ae446ca8ae` -Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) +Sources: [`packages/subagent/subagent/src/catalog.ts:32`](../packages/subagent/subagent/src/catalog.ts) One of: +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-8faed5b52ab2d2df40b94a926d0f0faa75280bddcc4b17b6bdd9c8bfed7c34b5) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a43327a1739c3fa477f32981071fef7d8ba6ae5f32a84fd70f547b2eee59e93e) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) -- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) @@ -5033,7 +5052,7 @@ Sources: [`packages/team/agent-team/src/types.ts:50`](../packages/team/agent-tea SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/team/agent-team/src/types.ts:108`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:112`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5053,7 +5072,7 @@ Sources: [`packages/team/agent-team/src/types.ts:108`](../packages/team/agent-te SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:121`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5063,7 +5082,7 @@ Sources: [`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-te | `senderName` | required | `string` | | `teamId` | required | `string` | - + @@ -5071,7 +5090,7 @@ Sources: [`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-te ### `TeamTaskSnapshot` -SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` +SHA-256: `4018a00144474b6c60dc1aa6e5c1f03611c66bd739833779a7d20a511df6d082` Sources: [`packages/team/agent-team/src/types.ts:64`](../packages/team/agent-team/src/types.ts) @@ -5081,6 +5100,7 @@ Sources: [`packages/team/agent-team/src/types.ts:64`](../packages/team/agent-tea | `description` | required | `string` | | `id` | required | `string` | | `ownerId` | optional | `string` | +| `ownerName` | optional | `string` | | `revision` | required | `number` | | `status` | required | [`TeamTaskStatus`](#persistence-type-sha256-b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943) | | `subject` | required | `string` | @@ -5418,7 +5438,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5426,7 +5446,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src ### `UserMessage` -SHA-256: `613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1` +SHA-256: `74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5435,19 +5455,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-dca3ba43188095fa7342e49e6c206d4685125497d4767595bf12a62ea2fd73ce) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. Attribution-only additions: `action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0` +SHA-256: `8a54d228b1b7d8453e4304d32723d489f28b4b33b221f335342146926ad94fc6` -Array of [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1). +Array of [`UserMessage`](#persistence-type-sha256-74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c). @@ -5639,7 +5659,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:389`](../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/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/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:391`](../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/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/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5984,6 +6004,21 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | required | `"continuable"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `8faed5b52ab2d2df40b94a926d0f0faa75280bddcc4b17b6bdd9c8bfed7c34b5` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | required | `string` | +| `mode` | required | `"continuable"` | +| `name` | required | `string` | +| `version` | required | `3` | + ### `{ childCreatedAt, childId, label, mode, … }` @@ -6026,6 +6061,21 @@ SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` | `mode` | required | `"unknown"` | | `version` | required | `1` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a43327a1739c3fa477f32981071fef7d8ba6ae5f32a84fd70f547b2eee59e93e` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"external"` | +| `name` | required | `string` | +| `version` | required | `3` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -6282,7 +6332,7 @@ Sources: [`packages/experimental/liveshare/src/host/viewer-principal.ts:29`](../ SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` -Sources: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6384,18 +6434,18 @@ Sources: [`packages/core/session/src/types.ts:398`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e` +SHA-256: `295809283ede248d14e6a431dec1c464cfd80a54b0d8f2a5994219fd8c8f9998` Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-824b1d4d4f007bdaa5eee7994a5db3ede7707c6cd2bf8e2509be176c7609e3bc) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6479,17 +6529,17 @@ Sources: [`packages/core/session/src/types.ts:435`](../packages/core/session/src |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51` +SHA-256: `f7752f518bb2f9165f2c01559df1f570c23d57373defbbd4512399a0ffaf2492` Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-8a54d228b1b7d8453e4304d32723d489f28b4b33b221f335342146926ad94fc6) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -7249,6 +7299,21 @@ SHA-256: `db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c` | `kind` | required | `"repeat-tool-reminder"` | | `sections` | required | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | + + +### `{ kind: "retry" }` + +SHA-256: `8f1062573ea9184129dfdec43df1e8136eb5808746fd809bf6b539f3fd11362e` + +Sources: [`packages/api/session-controller/src/types.ts:402`](../packages/api/session-controller/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `clientTimeZone` | optional | `string` | +| `kind` | required | `"retry"` | +| `principal` | optional | [`union (2 variants)`](#persistence-type-sha256-824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce) | +| `rpcId` | required | `string` | + ### `{ kind: "runtime-context" }` @@ -7759,7 +7824,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` -Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:400`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7912,7 +7977,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7940,7 +8005,7 @@ Sources: [`packages/core/session/src/types.ts:338`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:183`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7954,7 +8019,7 @@ Sources: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-te SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:185`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8094,17 +8159,17 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. |---|---|---| | `targets` | required | [`ImageOffloadTarget[]`](#persistence-type-sha256-9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb) | - + ### `{ task, teamId, version }` -SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` +SHA-256: `a9427866376b691aace0ce25aa0e1bdec2a14156a05152947a467da3f7967cb9` -Sources: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| -| `task` | required | [`TeamTaskSnapshot`](#persistence-type-sha256-51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357) | +| `task` | required | [`TeamTaskSnapshot`](#persistence-type-sha256-4018a00144474b6c60dc1aa6e5c1f03611c66bd739833779a7d20a511df6d082) | | `teamId` | required | `string` | | `version` | required | `2` | @@ -8148,17 +8213,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` +SHA-256: `1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-f7752f518bb2f9165f2c01559df1f570c23d57373defbbd4512399a0ffaf2492) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8399,17 +8464,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` +SHA-256: `510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-295809283ede248d14e6a431dec1c464cfd80a54b0d8f2a5994219fd8c8f9998) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8809,17 +8874,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` +SHA-256: `3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-bec5ca3a376f56f3d69df7f12a48e1cc07d4ea8305968773394c8734799faeb4) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8857,17 +8922,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` +SHA-256: `d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-058fc9a6b4fbd5d43437c3a64170002070dfc98e18d87de294b564ae446ca8ae) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8987,17 +9052,17 @@ SHA-256: `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | `time` | required | `number` | | `type` | required | `"team/mode"` | - + ### `{ type: "team/task" }` -SHA-256: `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` +SHA-256: `bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ task, teamId, version }`](#persistence-type-sha256-a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c) | +| `data` | required | [`{ task, teamId, version }`](#persistence-type-sha256-a9427866376b691aace0ce25aa0e1bdec2a14156a05152947a467da3f7967cb9) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -9258,17 +9323,17 @@ Sources: [`packages/llm/llm/src/types.ts:502`](../packages/llm/llm/src/types.ts) | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` +SHA-256: `50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 6b26e80ae8..73ebef9642 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482) | +| `event:agent/inbox/spliced` | event | `1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | [`{ type: "developer/message" }`](#persistence-type-sha256-70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a) | +| `event:developer/message` | event | `510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99` | [`{ type: "developer/message" }`](#persistence-type-sha256-510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -60,10 +60,10 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca) | +| `event:session/title-llm-request` | event | `3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | +| `event:subagent/catalog` | event | `d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -71,7 +71,7 @@ | `event:team/message/delivered` | event | `48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb` | [`{ type: "team/message/delivered" }`](#persistence-type-sha256-48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb) | | `event:team/message/queued` | event | `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | [`{ type: "team/message/queued" }`](#persistence-type-sha256-21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d) | | `event:team/mode` | event | `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | [`{ type: "team/mode" }`](#persistence-type-sha256-920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75) | -| `event:team/task` | event | `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` | [`{ type: "team/task" }`](#persistence-type-sha256-d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a) | +| `event:team/task` | event | `bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7` | [`{ type: "team/task" }`](#persistence-type-sha256-bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7) | | `event:todo/write` | event | `b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26` | [`{ type: "todo/write" }`](#persistence-type-sha256-b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26) | | `event:tool-workflow/agent-end` | event | `babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7` | [`{ type: "tool-workflow/agent-end" }`](#persistence-type-sha256-babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7) | | `event:tool-workflow/agent-start` | event | `5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627` | [`{ type: "tool-workflow/agent-start" }`](#persistence-type-sha256-5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627) | @@ -83,7 +83,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | [`{ type: "user/message" }`](#persistence-type-sha256-dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450) | +| `event:user/message` | event | `50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97` | [`{ type: "user/message" }`](#persistence-type-sha256-50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -940,7 +940,7 @@ export type SessionEvent = { 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:44`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:52`](../packages/subagent/subagent/src/catalog.ts) @@ -1017,7 +1017,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) @@ -1035,7 +1035,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:185`](../packages/team/agent-team/src/types.ts) @@ -1048,7 +1048,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:183`](../packages/team/agent-team/src/types.ts) @@ -1059,7 +1059,7 @@ export type SessionEvent = { 'team/mode': { enabled: boolean } ``` -来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) @@ -1072,7 +1072,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -2448,6 +2448,14 @@ SHA-256: `34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66` `"resume"` + + +### `"retry"` + +SHA-256: `63ba8895cc09e4693cf0819fdcd18c084bef195febda5b6a23af279b3852be36` + +`"retry"` + ### `"runtime-context"` @@ -3104,6 +3112,14 @@ SHA-256: `a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80` `2` + + +### `3` + +SHA-256: `137ac60e26bcb192210d9a32a704b73b0924ed34019323defa1b787be95bd45e` + +`3` + ### `4` @@ -3412,7 +3428,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` | `body` | 必需 | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | 必需 | `string` | - + @@ -3420,7 +3436,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` ### `DeveloperMessage` -SHA-256: `01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796` +SHA-256: `824b1d4d4f007bdaa5eee7994a5db3ede7707c6cd2bf8e2509be176c7609e3bc` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3429,7 +3445,7 @@ SHA-256: `01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-dca3ba43188095fa7342e49e6c206d4685125497d4767595bf12a62ea2fd73ce) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4128,7 +4144,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -4136,15 +4152,15 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39` +SHA-256: `c51f0d0a405bb96fcb008c0dc560a512dcd8c3919b6b446937f449ea2f7d21d8` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) -- [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) +- [`DeveloperMessage`](#persistence-type-sha256-824b1d4d4f007bdaa5eee7994a5db3ede7707c6cd2bf8e2509be176c7609e3bc) +- [`UserMessage`](#persistence-type-sha256-74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4221,7 +4237,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4229,7 +4245,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` +SHA-256: `dca3ba43188095fa7342e49e6c206d4685125497d4767595bf12a62ea2fd73ce` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4291,6 +4307,7 @@ SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` | `repeat-tool-reminder` | `"relay"` | 无 | [`{ kind: "repeat-tool-reminder", form: "relay" }`](#persistence-type-sha256-4991dfc415c9efebc36fa6969e1798c7d2e42396cc1e6e0ac7d7c89c5c5c4aea) | | `repeat-tool-reminder` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "repeat-tool-reminder", form: "snapshot" }`](#persistence-type-sha256-db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c) | | `repeat-tool-reminder` | 未声明 | 无 | [`{ kind: "repeat-tool-reminder" }`](#persistence-type-sha256-b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1) | +| `retry` | 未声明 | `rpcId`: `string` | [`{ kind: "retry" }`](#persistence-type-sha256-8f1062573ea9184129dfdec43df1e8136eb5808746fd809bf6b539f3fd11362e) | | `runtime-context` | `"catalog"` | 无 | [`{ kind: "runtime-context", form: "catalog" }`](#persistence-type-sha256-58015da1c1950a66daf66ae24913d6a1fc6d1d9ac441b155f8847f7f8584ba8e) | | `runtime-context` | `"instructions"` | 无 | [`{ kind: "runtime-context", form: "instructions" }`](#persistence-type-sha256-0eeb17e1df89b1ebc2902116fe7145e0d321ef0db26e6bdebc802cb8513a01c1) | | `runtime-context` | `"notice"` | `summary`: `string` | [`{ kind: "runtime-context", form: "notice" }`](#persistence-type-sha256-cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc) | @@ -4354,13 +4371,13 @@ SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70` +SHA-256: `42e2ff61928fb7dfaf0f057d61686f3a32ff713da419d12d4f250efc96733fe0` -[`Message`](#persistence-type-sha256-e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39) 的数组。 +[`Message`](#persistence-type-sha256-c51f0d0a405bb96fcb008c0dc560a512dcd8c3919b6b446937f449ea2f7d21d8) 的数组。 @@ -4728,7 +4745,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4736,7 +4753,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25` +SHA-256: `bec5ca3a376f56f3d69df7f12a48e1cc07d4ea8305968773394c8734799faeb4` 来源:[`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) @@ -4744,7 +4761,7 @@ SHA-256: `601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25` |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-42e2ff61928fb7dfaf0f057d61686f3a32ff713da419d12d4f250efc96733fe0) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4875,7 +4892,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4883,18 +4900,20 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87` +SHA-256: `058fc9a6b4fbd5d43437c3a64170002070dfc98e18d87de294b564ae446ca8ae` -来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:32`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-8faed5b52ab2d2df40b94a926d0f0faa75280bddcc4b17b6bdd9c8bfed7c34b5) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a43327a1739c3fa477f32981071fef7d8ba6ae5f32a84fd70f547b2eee59e93e) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) -- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) @@ -5035,7 +5054,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/team/agent-team/src/types.ts:108`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:112`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5055,7 +5074,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:121`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5065,7 +5084,7 @@ SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` | `senderName` | 必需 | `string` | | `teamId` | 必需 | `string` | - + @@ -5073,7 +5092,7 @@ SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` ### `TeamTaskSnapshot` -SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` +SHA-256: `4018a00144474b6c60dc1aa6e5c1f03611c66bd739833779a7d20a511df6d082` 来源:[`packages/team/agent-team/src/types.ts:64`](../packages/team/agent-team/src/types.ts) @@ -5083,6 +5102,7 @@ SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` | `description` | 必需 | `string` | | `id` | 必需 | `string` | | `ownerId` | 可选 | `string` | +| `ownerName` | 可选 | `string` | | `revision` | 必需 | `number` | | `status` | 必需 | [`TeamTaskStatus`](#persistence-type-sha256-b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943) | | `subject` | 必需 | `string` | @@ -5420,7 +5440,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5428,7 +5448,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1` +SHA-256: `74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5437,19 +5457,19 @@ SHA-256: `613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-dca3ba43188095fa7342e49e6c206d4685125497d4767595bf12a62ea2fd73ce) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. 仅表示归属的新增 kind:`action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0` +SHA-256: `8a54d228b1b7d8453e4304d32723d489f28b4b33b221f335342146926ad94fc6` -[`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) 的数组。 +[`UserMessage`](#persistence-type-sha256-74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c) 的数组。 @@ -5641,7 +5661,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:389`](../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/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/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:391`](../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/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/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5986,6 +6006,21 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | 必需 | `"continuable"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `8faed5b52ab2d2df40b94a926d0f0faa75280bddcc4b17b6bdd9c8bfed7c34b5` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 必需 | `string` | +| `mode` | 必需 | `"continuable"` | +| `name` | 必需 | `string` | +| `version` | 必需 | `3` | + ### `{ childCreatedAt, childId, label, mode, … }` @@ -6028,6 +6063,21 @@ SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` | `mode` | 必需 | `"unknown"` | | `version` | 必需 | `1` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a43327a1739c3fa477f32981071fef7d8ba6ae5f32a84fd70f547b2eee59e93e` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"external"` | +| `name` | 必需 | `string` | +| `version` | 必需 | `3` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -6284,7 +6334,7 @@ SHA-256: `0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623` SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` -来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6386,18 +6436,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e` +SHA-256: `295809283ede248d14e6a431dec1c464cfd80a54b0d8f2a5994219fd8c8f9998` 来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-824b1d4d4f007bdaa5eee7994a5db3ede7707c6cd2bf8e2509be176c7609e3bc) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6481,17 +6531,17 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51` +SHA-256: `f7752f518bb2f9165f2c01559df1f570c23d57373defbbd4512399a0ffaf2492` 来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-8a54d228b1b7d8453e4304d32723d489f28b4b33b221f335342146926ad94fc6) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -7251,6 +7301,21 @@ SHA-256: `db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c` | `kind` | 必需 | `"repeat-tool-reminder"` | | `sections` | 必需 | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | + + +### `{ kind: "retry" }` + +SHA-256: `8f1062573ea9184129dfdec43df1e8136eb5808746fd809bf6b539f3fd11362e` + +来源:[`packages/api/session-controller/src/types.ts:402`](../packages/api/session-controller/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `clientTimeZone` | 可选 | `string` | +| `kind` | 必需 | `"retry"` | +| `principal` | 可选 | [`union (2 variants)`](#persistence-type-sha256-824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce) | +| `rpcId` | 必需 | `string` | + ### `{ kind: "runtime-context" }` @@ -7761,7 +7826,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` -来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:400`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7914,7 +7979,7 @@ SHA-256: `9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7942,7 +8007,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:183`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7956,7 +8021,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:185`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8096,17 +8161,17 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` |---|---|---| | `targets` | 必需 | [`ImageOffloadTarget[]`](#persistence-type-sha256-9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb) | - + ### `{ task, teamId, version }` -SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` +SHA-256: `a9427866376b691aace0ce25aa0e1bdec2a14156a05152947a467da3f7967cb9` -来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `task` | 必需 | [`TeamTaskSnapshot`](#persistence-type-sha256-51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357) | +| `task` | 必需 | [`TeamTaskSnapshot`](#persistence-type-sha256-4018a00144474b6c60dc1aa6e5c1f03611c66bd739833779a7d20a511df6d082) | | `teamId` | 必需 | `string` | | `version` | 必需 | `2` | @@ -8150,17 +8215,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` +SHA-256: `1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-f7752f518bb2f9165f2c01559df1f570c23d57373defbbd4512399a0ffaf2492) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8401,17 +8466,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` +SHA-256: `510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-295809283ede248d14e6a431dec1c464cfd80a54b0d8f2a5994219fd8c8f9998) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8811,17 +8876,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` +SHA-256: `3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-bec5ca3a376f56f3d69df7f12a48e1cc07d4ea8305968773394c8734799faeb4) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8859,17 +8924,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` +SHA-256: `d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-058fc9a6b4fbd5d43437c3a64170002070dfc98e18d87de294b564ae446ca8ae) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8989,17 +9054,17 @@ SHA-256: `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | `time` | 必需 | `number` | | `type` | 必需 | `"team/mode"` | - + ### `{ type: "team/task" }` -SHA-256: `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` +SHA-256: `bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ task, teamId, version }`](#persistence-type-sha256-a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c) | +| `data` | 必需 | [`{ task, teamId, version }`](#persistence-type-sha256-a9427866376b691aace0ce25aa0e1bdec2a14156a05152947a467da3f7967cb9) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -9260,17 +9325,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` +SHA-256: `50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml b/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml new file mode 100644 index 0000000000..c9979c895d --- /dev/null +++ b/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-27-retry-control-input.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/persistence-changes/2026-10-27-retry-control-input.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-27-retry-control-input: + en: 32c7e9cc4a9f86b4 + zh: f1cb7169df15e8e4 +/2026-10-27-retry-control-input/summary: + en: 77dd9a1b2db8d968 + zh: c3d0111e34b27fe3 +/2026-10-27-retry-control-input/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-27-retry-control-input/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-27-retry-control-input/compatibility: + en: 6dee29fbf0f77087 + zh: 72e0cfdef298a75e +/2026-10-27-retry-control-input/verification: + en: 06eee3f214f278b8 + zh: d044457dd6e5b8c3 +/2026-10-27-retry-control-input/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.md b/docs/persistence-changes/2026-10-27-retry-control-input.md new file mode 100644 index 0000000000..b97bfca4e8 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-retry-control-input.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-27-retry-control-input + +English | [中文](2026-10-27-retry-control-input.zh.md) + +## Summary + +Add the retry message source for empty Session API continuation prompts; ordinary user/message records retain the input identity and caller metadata. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-27-retry-control-input +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-27-subagent-settlement-attribution" + after: "1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-27-subagent-settlement-attribution" + after: "510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-27-subagent-settlement-attribution" + after: "3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18" + decision: same-version + - root: "event:user/message" + previous: "2026-10-27-subagent-settlement-attribution" + after: "50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97" + decision: same-version +``` + + +## Compatibility + +The retry source is producer-owned metadata on the existing empty UserMessage representation. AgentLoop, model-history derivation, and SDK prompt behavior are unchanged. Older readers preserve extensible source metadata and consume these messages through ordinary user-message admission; they may display an empty context row. Updated readers retain historical ordinary messages and hide only empty retry messages in Chat. Existing event discriminators and the current Session version remain unchanged; no new required event is introduced. + + +## Verification + +The Session API suite passed 1112 tests across 51 files; continuation.ts has 100% statements, branches, functions, and lines. Chat and conversation tests passed 1875 tests across 113 files; final focused Chat and input regressions passed 96 and 31 tests. The browser continuation replay passed with an empty retry user message retained in the log, no empty Chat bubble, and normally closed turns. Build passed all 10 gates, typed lint exited 0, and doc-sync passed all 43 gates. Git comparison against the PR base confirms no AgentLoop, Core Agent, Core Session, or SDK changes. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.schema.json b/docs/persistence-changes/2026-10-27-retry-control-input.schema.json new file mode 100644 index 0000000000..0a5d3dab79 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-retry-control-input.schema.json @@ -0,0 +1,261 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97"} + ], + "types": [], + "nodes": { + "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]}, + "05205074b24d8340": {"kind":"literal","value":"action-cards"}, + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"}, + "09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]}, + "0a92c5bdfa06c2e2": {"kind":"object","properties":[{"name":"displayName","type":"4cd48f3e5108bd6e","optional":true},{"name":"providerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subjectId","type":"4cd48f3e5108bd6e","optional":false},{"name":"tenantId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "0fe52387f6505270": {"kind":"literal","value":"plan-mode"}, + "11638dc58a31496f": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]}, + "1379f0b72275994d": {"kind":"literal","value":"developer/message"}, + "1682631f010d5ea7": {"kind":"object","properties":[{"name":"data","type":"f7752f518bb2f916","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, + "16d0507cb94b4cfc": {"kind":"literal","value":"relay"}, + "1922cd79a70806c1": {"kind":"literal","value":"skill-invocation"}, + "1938c8c20f1cba7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1e0b0b66b6f0c53f": {"kind":"literal","value":"aborted"}, + "1e709346005826eb": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1ebc6f9ff3aa9e3a": {"kind":"literal","value":"completed"}, + "1f0133217eeeffd8": {"kind":"literal","value":"tool-call"}, + "1f1385dbc3e875c0": {"kind":"literal","value":"repeat-tool-reminder"}, + "1fdad624334d3548": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "2152caf1a71e588e": {"kind":"object","properties":[{"name":"kind","type":"2e4debca12f2c449","optional":false},{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "2235d957bf4b7c6c": {"kind":"object","properties":[{"name":"kind","type":"7b1ce60193fa8b69","optional":false}],"indices":[]}, + "224aa11d4d5f88c4": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "23af3759678a30de": {"kind":"literal","value":"dsh-session-title-llm"}, + "24b10f7cdae64928": {"kind":"literal","value":"runtime-context"}, + "24ce31aa528272e5": {"kind":"object","properties":[{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "260523b412c19ad8": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "2765e9119edf5816": {"kind":"literal","value":"model"}, + "277b61f9c6f81704": {"kind":"literal","value":"file"}, + "28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "295809283ede248d": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"824b1d4d4f007bda","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"}, + "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, + "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, + "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, + "312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "3247e566f4340029": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]}, + "383cf73ae4748e5b": {"kind":"literal","value":"recall"}, + "3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "3bca25b2baa6b22b": {"kind":"object","properties":[{"name":"data","type":"bec5ca3a376f56f3","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, + "3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]}, + "3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"}, + "3e2ece4967cfebe9": {"kind":"literal","value":"next-step"}, + "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, + "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "42e2ff61928fb7df": {"kind":"array","element":"c51f0d0a405bb96f"}, + "43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]}, + "45f005e6bbb76452": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "4a561c111a78d452": {"kind":"literal","value":"refusal"}, + "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4b259f7f8f0a44ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"role","type":"a13d8f379222ece8","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "4d60dddaa65d3da8": {"kind":"object","properties":[{"name":"actor","type":"0a92c5bdfa06c2e2","optional":false},{"name":"kind","type":"6ba7edb6447b6a3e","optional":false},{"name":"shareId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4e44c9118152f682": {"kind":"literal","value":"text"}, + "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, + "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, + "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, + "50a15408d511e9be": {"kind":"object","properties":[{"name":"data","type":"74e0e5885d2ddf04","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, + "510826cf4e1f9220": {"kind":"object","properties":[{"name":"data","type":"295809283ede248d","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, + "52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "58015da1c1950a66": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"}, + "589d0d91239681f8": {"kind":"literal","value":"developer"}, + "58f94390b982f8a5": {"kind":"literal","value":"image/png"}, + "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"}, + "5d7a64ed6aac5551": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, + "63ba8895cc09e469": {"kind":"literal","value":"retry"}, + "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, + "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, + "672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "68141cbe3994a5ff": {"kind":"literal","value":"max-tokens"}, + "68f44f59e91c4139": {"kind":"literal","value":"user"}, + "6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"}, + "6ba7edb6447b6a3e": {"kind":"literal","value":"share-viewer"}, + "6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"}, + "6f22a9d02b9a1f11": {"kind":"object","properties":[{"name":"baseline","type":"dacf3d5f3e4ff619","optional":true},{"name":"baselineIdentity","type":"4cd48f3e5108bd6e","optional":true},{"name":"changes","type":"2ab12a0e345658f9","optional":false},{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"fbd2c74b346a1595","optional":false}],"indices":[]}, + "6f44f32be9bcd9a8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, + "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, + "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"}, + "74e0e5885d2ddf04": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"dca3ba43188095fa","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "78a9be340090b735": {"kind":"literal","value":"tmux-context"}, + "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, + "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, + "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, + "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, + "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]}, + "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"}, + "824a4aad44a54e24": {"kind":"union","types":["4d60dddaa65d3da8","deebfc7c62177ea8"]}, + "824b1d4d4f007bda": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"dca3ba43188095fa","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"}, + "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8338ee303e2bd448": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "83d6916021eede8f": {"kind":"literal","value":"tool-removal"}, + "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "848639f8787f035b": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"866f7b2dbdc5b421","optional":true},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "85d6d96ff8842974": {"kind":"literal","value":"answered"}, + "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"}, + "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "866f7b2dbdc5b421": {"kind":"union","types":["1e0b0b66b6f0c53f","1ebc6f9ff3aa9e3a","4a561c111a78d452","68141cbe3994a5ff","a2be0f0cb59f0d4a"]}, + "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "87b1a33564b9a4d6": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "8a54d228b1b7d845": {"kind":"array","element":"74e0e5885d2ddf04"}, + "8af841fc9b265ade": {"kind":"literal","value":"system"}, + "8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]}, + "8f1062573ea91841": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"63ba8895cc09e469","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, + "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, + "938f7c7357bc403e": {"kind":"primitive","type":"null"}, + "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, + "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, + "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, + "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]}, + "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "99e7e7d7fed55ae6": {"kind":"literal","value":"tool-jobs"}, + "99f0e98184709ad1": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "9ac52f0f84fec2eb": {"kind":"literal","value":"model-selection"}, + "9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "a13d8f379222ece8": {"kind":"literal","value":"tool"}, + "a15a19ef63cf5d1b": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "a16e567c8cd2c55c": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "a2066bd52f61218b": {"kind":"object","properties":[{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "a2be0f0cb59f0d4a": {"kind":"literal","value":"error"}, + "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a4afec59b75aad5d": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a720757f704a2512": {"kind":"literal","value":"tool-registry"}, + "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"}, + "aa2bf9e82b9fdeb9": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, + "aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"}, + "ada310bf0bdb8fed": {"kind":"object","properties":[{"name":"kind","type":"68f44f59e91c4139","optional":false}],"indices":[]}, + "ade057f4fdc387ae": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "aec150797b655c5f": {"kind":"literal","value":"catalog"}, + "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b52f215a6cd69105": {"kind":"array","element":"8064c03c87c357fd"}, + "b613f83572fe2fbf": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "b765ee31033cea46": {"kind":"literal","value":"set"}, + "b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "b815d7b91cdd9fc6": {"kind":"literal","value":"goal"}, + "ba2a1a8f194b1bb2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "baccc97ec34be6c4": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"}, + "bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"}, + "bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "bec5ca3a376f56f3": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"42e2ff61928fb7df","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"}, + "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]}, + "c51f0d0a405bb96f": {"kind":"union","types":["09f3f8dc417f2782","4b259f7f8f0a44ad","74e0e5885d2ddf04","824b1d4d4f007bda","fa87537f4a9c1e25"]}, + "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]}, + "c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"}, + "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, + "cb1c8959e1726f82": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, + "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, + "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true}, + "db06ef35b27a3842": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "db6c5246911d47a7": {"kind":"literal","value":"webhook"}, + "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, + "dca3ba43188095fa": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","848639f8787f035b","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f1062573ea91841","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, + "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "de25352d2dc41963": {"kind":"literal","value":"user/message"}, + "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, + "deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "deebfc7c62177ea8": {"kind":"object","properties":[{"name":"kind","type":"95a3ffccbf3c8afc","optional":false}],"indices":[]}, + "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, + "e438a9d4fb883b3e": {"kind":"literal","value":"append"}, + "e7f4cbb3a57f4cbd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "e8de0f457fed5b9f": {"kind":"literal","value":"notice"}, + "ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"}, + "edbbfff1128099fd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "ee577a0cc18664c9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f230c0b583e23b25": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "f29d63336e8266d8": {"kind":"object","properties":[{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]}, + "f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f6605d6c57933aea": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f68c335306535a00": {"kind":"literal","value":"user-approval"}, + "f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "f6cb462f808309e3": {"kind":"literal","value":"replace"}, + "f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f7752f518bb2f916": {"kind":"object","properties":[{"name":"inserted","type":"8a54d228b1b7d845","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, + "f88f1b7b6bb733f6": {"kind":"literal","value":1}, + "f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]}, + "fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"}, + "fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"}, + "fcf427290c6213c2": {"kind":"primitive","type":"never"}, + "fcf81d3c58c13593": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"} + } +} diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.zh.md b/docs/persistence-changes/2026-10-27-retry-control-input.zh.md new file mode 100644 index 0000000000..119eab180a --- /dev/null +++ b/docs/persistence-changes/2026-10-27-retry-control-input.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-27-retry-control-input + +[English](2026-10-27-retry-control-input.md) | 中文 + +## 概述 + +为空 Session API 继续提示词增加 retry 消息来源;普通 user/message 记录保留输入身份和调用方元数据。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-27-retry-control-input +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-27-subagent-settlement-attribution" + after: "1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-27-subagent-settlement-attribution" + after: "510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-27-subagent-settlement-attribution" + after: "3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18" + decision: same-version + - root: "event:user/message" + previous: "2026-10-27-subagent-settlement-attribution" + after: "50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97" + decision: same-version +``` + + +## 兼容性 + +retry 来源是现有空 UserMessage 表示中的生产方元数据。AgentLoop、模型历史派生与 SDK 提示词行为保持不变。旧读取方保留可扩展来源元数据,并按普通用户消息准入消费;旧界面可能显示空上下文行。更新后的读取方保留历史普通消息,Chat 仅隐藏空重试消息。现有事件判别标签与当前 Session 版本不变,不新增必读事件。 + + +## 验证 + +Session API 测试通过:51 个文件、1112 个测试,continuation.ts 的语句、分支、函数和行覆盖率均为 100%。Chat 与 conversation 测试通过:113 个文件、1875 个测试;最终定向 Chat 与输入回归分别通过 96 和 31 个测试。浏览器继续回放通过,断言日志保留空 retry 用户消息、Chat 无空气泡且轮次正常闭合。构建 10 项全部通过,类型感知 lint 退出码为 0,doc-sync 的 43 项全部通过。与 PR 基线的 Git 比较确认 AgentLoop、Core Agent、Core Session 和 SDK 无改动。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/2026-11-02-subagent-catalog-names.i18n.yaml b/docs/persistence-changes/2026-11-02-subagent-catalog-names.i18n.yaml new file mode 100644 index 0000000000..95b4ae78d4 --- /dev/null +++ b/docs/persistence-changes/2026-11-02-subagent-catalog-names.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-11-02-subagent-catalog-names.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/persistence-changes/2026-11-02-subagent-catalog-names.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-11-02-subagent-catalog-names: + en: d60db721936ec588 + zh: 9271248e40f283c5 +/2026-11-02-subagent-catalog-names/summary: + en: af5f42653bb5d143 + zh: 4441c72b0ab5d2b8 +/2026-11-02-subagent-catalog-names/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-11-02-subagent-catalog-names/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-11-02-subagent-catalog-names/compatibility: + en: 29b5c0a1e2ce852c + zh: 6ce9b7d6baf58fc2 +/2026-11-02-subagent-catalog-names/verification: + en: cf8533363c2d36f7 + zh: 75de24564998ca27 +/2026-11-02-subagent-catalog-names/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-11-02-subagent-catalog-names.md b/docs/persistence-changes/2026-11-02-subagent-catalog-names.md new file mode 100644 index 0000000000..b6f784306e --- /dev/null +++ b/docs/persistence-changes/2026-11-02-subagent-catalog-names.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-11-02-subagent-catalog-names + +English | [中文](2026-11-02-subagent-catalog-names.zh.md) + +## Summary + +Adds version 3 of the parent-owned subagent/catalog payload, which records each continuable or parent-delivered external child's name, unique among its creator's children. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-11-02-subagent-catalog-names +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-19-external-subagent-catalog" + after: "d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41" + decision: same-version +``` + + +## Compatibility + +Version 0, 1, and 2 payloads keep their schemas and meaning; current readers restore them as unnamed children addressed by Session id. New local and parent-delivered external creation writes version 3. Projection stateVersion remains 6: unnamed checkpoints stay usable, and their suffix replay matches full replay with named events. Earlier releases reject the unknown payload version during native V4 admission and catalog projection restoration instead of misreading the entry, so a parent Session that recorded a named child fails loudly rather than losing a child. + + +## Verification + +pnpm exec vitest run packages/subagent/subagent/tests: 495 tests passed, including named catalog restoration, invalid version-3 facts, sibling-unique reservation, cold resume path recovery, and external parent-delivered names. pnpm exec vitest run packages/session/session-format-v3-to-v4/tests: 397 tests passed, including native version-3 admission and rejection. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-11-02-subagent-catalog-names.schema.json b/docs/persistence-changes/2026-11-02-subagent-catalog-names.schema.json new file mode 100644 index 0000000000..9a474fce46 --- /dev/null +++ b/docs/persistence-changes/2026-11-02-subagent-catalog-names.schema.json @@ -0,0 +1,31 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41"} + ], + "types": [], + "nodes": { + "058fc9a6b4fbd5d4": {"kind":"union","types":["0e10d7c096b47e61","5d542ac7bdf13310","8faed5b52ab2d2df","a0f25b5aeeebf2bf","a43327a1739c3fa4","b6896938660a1dfe","cbbe8377ee744964","e2ad8f5bcd813f26"]}, + "05a2796621b1ee6c": {"kind":"literal","value":"unknown"}, + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "0e10d7c096b47e61": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"version","type":"93d38436347f67c9","optional":false}],"indices":[]}, + "137ac60e26bcb192": {"kind":"literal","value":3}, + "44c8c77aa9aa100b": {"kind":"literal","value":"subagent/catalog"}, + "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "5d542ac7bdf13310": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"version","type":"93d38436347f67c9","optional":false}],"indices":[]}, + "8faed5b52ab2d2df": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"137ac60e26bcb192","optional":false}],"indices":[]}, + "93d38436347f67c9": {"kind":"literal","value":0}, + "a0f25b5aeeebf2bf": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"05a2796621b1ee6c","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "a0f906856fc06f1c": {"kind":"literal","value":2}, + "a43327a1739c3fa4": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"fc351843b8ecbd05","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"137ac60e26bcb192","optional":false}],"indices":[]}, + "b6896938660a1dfe": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "cbbe8377ee744964": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"fc351843b8ecbd05","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, + "d28765f87d6b6ab4": {"kind":"literal","value":"one-shot"}, + "d49164d37eab0f0f": {"kind":"object","properties":[{"name":"data","type":"058fc9a6b4fbd5d4","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44c8c77aa9aa100b","optional":false}],"indices":[]}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true}, + "e2ad8f5bcd813f26": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "e5d3828df1ec3e2a": {"kind":"literal","value":"continuable"}, + "f88f1b7b6bb733f6": {"kind":"literal","value":1}, + "fc351843b8ecbd05": {"kind":"literal","value":"external"} + } +} diff --git a/docs/persistence-changes/2026-11-02-subagent-catalog-names.zh.md b/docs/persistence-changes/2026-11-02-subagent-catalog-names.zh.md new file mode 100644 index 0000000000..15ad8ad6b5 --- /dev/null +++ b/docs/persistence-changes/2026-11-02-subagent-catalog-names.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-11-02-subagent-catalog-names + +[English](2026-11-02-subagent-catalog-names.md) | 中文 + +## 概述 + +为父 Session 拥有的 subagent/catalog 载荷增加 version 3,记录每个 continuable 子代理或父级交付的 external 子代理在其创建者子代理中唯一的名称。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-11-02-subagent-catalog-names +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-19-external-subagent-catalog" + after: "d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41" + decision: same-version +``` + + +## 兼容性 + +version 0、1、2 载荷的 schema 和含义保持不变;当前读取方将其恢复为按 Session id 寻址的无名子代理。新的本地创建和父级交付的 external 创建写入 version 3。投影 stateVersion 保持为 6:无名检查点仍可复用,后缀回放与包含具名事件的全量回放一致。较早版本在原生 V4 准入和目录投影恢复时拒绝未知载荷版本,而不会误读条目,因此记录了具名子代理的父 Session 会明确失败,而不是丢失子代理。 + + +## 验证 + +pnpm exec vitest run packages/subagent/subagent/tests:495 个测试通过,覆盖具名目录恢复、无效 version 3 事实、同级唯一预留、冷恢复路径还原以及父级交付 external 子代理的名称。pnpm exec vitest run packages/session/session-format-v3-to-v4/tests:397 个测试通过,覆盖原生 version 3 准入与拒绝。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/2026-11-02-team-task-owner-names.i18n.yaml b/docs/persistence-changes/2026-11-02-team-task-owner-names.i18n.yaml new file mode 100644 index 0000000000..95205ac30f --- /dev/null +++ b/docs/persistence-changes/2026-11-02-team-task-owner-names.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-11-02-team-task-owner-names.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/persistence-changes/2026-11-02-team-task-owner-names.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-11-02-team-task-owner-names: + en: a43808c05ced0aee + zh: 44561dea17e94aaa +/2026-11-02-team-task-owner-names/summary: + en: 4736e13a29707934 + zh: 7d9a2e65a31a5c65 +/2026-11-02-team-task-owner-names/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-11-02-team-task-owner-names/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-11-02-team-task-owner-names/compatibility: + en: ffa0669cd780f696 + zh: cdc5a302b3fc4ee1 +/2026-11-02-team-task-owner-names/verification: + en: 90d1e1c177ff1fc0 + zh: 6438d6dcb53596b8 +/2026-11-02-team-task-owner-names/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-11-02-team-task-owner-names.md b/docs/persistence-changes/2026-11-02-team-task-owner-names.md new file mode 100644 index 0000000000..5b65f23bed --- /dev/null +++ b/docs/persistence-changes/2026-11-02-team-task-owner-names.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-11-02-team-task-owner-names + +English | [中文](2026-11-02-team-task-owner-names.zh.md) + +## Summary + +Adds optional ownerName to durable Team task snapshots, captured with ownerId during claim or reassignment and cleared with it when ownership ends. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-11-02-team-task-owner-names +baseline: false +changes: + - root: "event:team/task" + previous: "2026-09-11-initial" + after: "bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7" + decision: same-version +``` + + +## Compatibility + +Current readers accept existing task payloads without ownerName and display ownerId without rewriting or backfilling historical logs. New task records retain the same ownerId authorization and version 2 task semantics. Earlier Team projections use a strict task schema and reject records containing ownerName, retaining their last valid tasks and reporting a projection failure rather than silently misinterpreting ownership. Session format version remains unchanged. The agentTeam projection state version remains 7: version-7 checkpoints without ownerName remain usable, and suffix replay preserves names from later task events. Version-6 checkpoints still rebuild from events. + + +## Verification + +Focused Agent Teams projection, task-board, tool, and JSONL/SQLite recovery tests pass. They cover saved root and nested owner names, clearing ownership, historical ID fallback, rejection of ownerName without ownerId, checkpoint rebuilds, and task reads and edits without descendant catalog queries. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-11-02-team-task-owner-names.schema.json b/docs/persistence-changes/2026-11-02-team-task-owner-names.schema.json new file mode 100644 index 0000000000..a75163d468 --- /dev/null +++ b/docs/persistence-changes/2026-11-02-team-task-owner-names.schema.json @@ -0,0 +1,23 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:team/task","kind":"event","event":"team/task","surface":false,"digest":"bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7"} + ], + "types": [], + "nodes": { + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "1ebc6f9ff3aa9e3a": {"kind":"literal","value":"completed"}, + "234142ee4f996f38": {"kind":"literal","value":"pending"}, + "4018a00144474b6c": {"kind":"object","properties":[{"name":"blockedBy","type":"93c33d9687613293","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"ownerId","type":"4cd48f3e5108bd6e","optional":true},{"name":"ownerName","type":"4cd48f3e5108bd6e","optional":true},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"status","type":"b2d2a01e51341a3b","optional":false},{"name":"subject","type":"4cd48f3e5108bd6e","optional":false},{"name":"writeScopes","type":"93c33d9687613293","optional":false}],"indices":[]}, + "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "93c33d9687613293": {"kind":"array","element":"4cd48f3e5108bd6e"}, + "94c56aeb1564ad73": {"kind":"literal","value":"in_progress"}, + "a0f906856fc06f1c": {"kind":"literal","value":2}, + "a9427866376b691a": {"kind":"object","properties":[{"name":"task","type":"4018a00144474b6c","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, + "b2d2a01e51341a3b": {"kind":"union","types":["1ebc6f9ff3aa9e3a","234142ee4f996f38","94c56aeb1564ad73","e7e8f1318170b828"]}, + "b57ae1b37e52bee0": {"kind":"literal","value":"team/task"}, + "bca0f9cb63d8620b": {"kind":"object","properties":[{"name":"data","type":"a9427866376b691a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b57ae1b37e52bee0","optional":false}],"indices":[]}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true}, + "e7e8f1318170b828": {"kind":"literal","value":"deleted"} + } +} diff --git a/docs/persistence-changes/2026-11-02-team-task-owner-names.zh.md b/docs/persistence-changes/2026-11-02-team-task-owner-names.zh.md new file mode 100644 index 0000000000..c4c4f8200d --- /dev/null +++ b/docs/persistence-changes/2026-11-02-team-task-owner-names.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-11-02-team-task-owner-names + +[English](2026-11-02-team-task-owner-names.md) | 中文 + +## 概述 + +为持久化 Team 任务快照增加可选的 ownerName,在认领或重新分配时与 ownerId 一起记录,并在结束归属时一起清除。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-11-02-team-task-owner-names +baseline: false +changes: + - root: "event:team/task" + previous: "2026-09-11-initial" + after: "bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7" + decision: same-version +``` + + +## 兼容性 + +当前读取方接受没有 ownerName 的已有任务载荷,直接显示 ownerId,不改写或回填历史日志。新任务记录保留相同的 ownerId 授权和第 2 版任务语义。旧 Team 投影使用严格任务 schema,会拒绝包含 ownerName 的记录,保留最后的有效任务并报告投影失败,不会静默误读归属。Session 格式版本保持不变。agentTeam 投影状态版本保持为 7:缺少 ownerName 的第 7 版检查点仍可复用,后缀回放保留后续任务事件中的名称。第 6 版检查点仍从事件重建。 + + +## 验证 + +聚焦的 Agent Teams 投影、任务板、工具及 JSONL/SQLite 恢复测试通过,覆盖保存的根与嵌套所有者名称、清除归属、历史 ID 回退、拒绝没有 ownerId 的 ownerName、检查点重建,以及不查询后代目录的任务读取和编辑。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index fcd95ef300..81275db31b 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: a660985fd2941e51 - zh: 0d4aed6be93be922 + en: 1ebef675a763819c + zh: 19457eb77b815c8c /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 24b42751fa..ad213e0c18 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 66 / 631 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 66 / 636 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index ef48b880a4..e3ca2d1bd3 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 66 / 631 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 66 / 636 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index a025a3f846..57c674e416 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -5,7 +5,7 @@ {"key":"JsonlHeaderLine","kind":"header","digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"}, {"key":"SessionEventEnvelope","kind":"envelope","digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"}, {"key":"event:agent-preset/selected","kind":"event","event":"agent-preset/selected","surface":false,"digest":"a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"}, - {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482"}, + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85"}, {"key":"event:approval/asked","kind":"event","event":"approval/asked","surface":false,"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"}, {"key":"event:approval/decided","kind":"event","event":"approval/decided","surface":false,"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"}, {"key":"event:approval/policy","kind":"event","event":"approval/policy","surface":false,"digest":"26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"}, @@ -18,7 +18,7 @@ {"key":"event:compaction/start","kind":"event","event":"compaction/start","surface":false,"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"}, {"key":"event:compaction/summary","kind":"event","event":"compaction/summary","surface":false,"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"}, {"key":"event:deliverables/presented","kind":"event","event":"deliverables/presented","surface":false,"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"}, - {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99"}, {"key":"event:feedback/message-delete","kind":"event","event":"feedback/message-delete","surface":false,"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"}, {"key":"event:feedback/message-put","kind":"event","event":"feedback/message-put","surface":false,"digest":"b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"}, {"key":"event:feedback/record","kind":"event","event":"feedback/record","surface":false,"digest":"b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"}, @@ -40,10 +40,10 @@ {"key":"event:session-log-deepseek/delivery-accepted","kind":"event","event":"session-log-deepseek/delivery-accepted","surface":false,"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"}, {"key":"event:session/end-seed","kind":"event","event":"session/end-seed","surface":false,"digest":"5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"}, {"key":"event:session/title","kind":"event","event":"session/title","surface":false,"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"}, - {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18"}, {"key":"event:step/end","kind":"event","event":"step/end","surface":false,"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"}, {"key":"event:step/start","kind":"event","event":"step/start","surface":false,"digest":"4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"}, - {"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac"}, + {"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41"}, {"key":"event:subagent/descriptor","kind":"event","event":"subagent/descriptor","surface":false,"digest":"b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5"}, {"key":"event:subagent/model-selection-policy","kind":"event","event":"subagent/model-selection-policy","surface":false,"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc"}, {"key":"event:system/message","kind":"event","event":"system/message","surface":true,"digest":"69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1"}, @@ -51,7 +51,7 @@ {"key":"event:team/message/delivered","kind":"event","event":"team/message/delivered","surface":false,"digest":"48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb"}, {"key":"event:team/message/queued","kind":"event","event":"team/message/queued","surface":false,"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d"}, {"key":"event:team/mode","kind":"event","event":"team/mode","surface":false,"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75"}, - {"key":"event:team/task","kind":"event","event":"team/task","surface":false,"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a"}, + {"key":"event:team/task","kind":"event","event":"team/task","surface":false,"digest":"bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7"}, {"key":"event:todo/write","kind":"event","event":"todo/write","surface":false,"digest":"b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26"}, {"key":"event:tool-workflow/agent-end","kind":"event","event":"tool-workflow/agent-end","surface":false,"digest":"babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7"}, {"key":"event:tool-workflow/agent-start","kind":"event","event":"tool-workflow/agent-start","surface":false,"digest":"5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627"}, @@ -63,20 +63,20 @@ {"key":"event:tool/result","kind":"event","event":"tool/result","surface":true,"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"}, {"key":"event:turn/end","kind":"event","event":"turn/end","surface":false,"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"}, {"key":"event:turn/start","kind":"event","event":"turn/start","surface":false,"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"}, - {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97"}, {"key":"event:web/deepseek-search-llm-request","kind":"event","event":"web/deepseek-search-llm-request","surface":false,"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52"}, {"key":"event:working-directory/change","kind":"event","event":"working-directory/change","surface":false,"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"}, {"key":"event:workspace/changes","kind":"event","event":"workspace/changes","surface":false,"digest":"e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"} ], "types": [ {"digest":"0116b1f89c05221d56b0ba35c83d302bf4857bc7277d247e9bbaf74b27d2c66e","names":[],"sources":[]}, - {"digest":"01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, {"digest":"0203243b72fc867afc81aff5f4cf2167e564ed342534eb612fc92f295cf07789","names":[],"sources":[]}, {"digest":"022623b9ef98ec47ec3c3bae0c51027c70d2601141c5e92e26a0d83d1b4cef06","names":[],"sources":[]}, {"digest":"023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4","names":[],"sources":["packages/core/session/src/types.ts:211"]}, {"digest":"02b2c23f2d94b028c9588a8faace3fce1217405db346da3389368b495457bf79","names":[],"sources":["packages/extensions/cordis-host-runner/src/index.ts:14"]}, {"digest":"0341d4c3dfcd53253cdc1e789d9130d3cd848b6f8b0706bf92f313a68b39ff23","names":[],"sources":[]}, {"digest":"05205074b24d8340cfd7995f6cb64779c7f1241490954700115601e264178877","names":[],"sources":[]}, + {"digest":"058fc9a6b4fbd5d43437c3a64170002070dfc98e18d87de294b564ae446ca8ae","names":["packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent"],"sources":["packages/subagent/subagent/src/catalog.ts:32"]}, {"digest":"05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04","names":[],"sources":[]}, {"digest":"05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf","names":["packages/interaction/user-approval/src/index.ts#ApprovalPolicy"],"sources":["packages/interaction/user-approval/src/index.ts:68"]}, {"digest":"0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7","names":["packages/core/session/src/types.ts#SessionSeq"],"sources":["packages/core/session/src/types.ts:33"]}, @@ -92,7 +92,7 @@ {"digest":"0af3ecfb8c60d416fc40a7cc4fb2c4851a8e46608b2b38ab9e510670c971aa1f","names":[],"sources":[]}, {"digest":"0b6d718c693fdf7f06dcccb0bcbbabe78cc63514eafc62d6b2a3899735279d6f","names":["packages/goal/goal/src/types.ts#GoalRef"],"sources":["packages/goal/goal/src/types.ts:20"]}, {"digest":"0b9b8facbe2c19773b3fb0ecc824ccbcc603d41b8f18069faa3807d449572fce","names":[],"sources":[]}, - {"digest":"0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1","names":[],"sources":["packages/team/agent-team/src/types.ts:181"]}, + {"digest":"0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1","names":[],"sources":["packages/team/agent-team/src/types.ts:185"]}, {"digest":"0bdaf9cb63594be4ef034b68901fffe792fe09d6d5f6bac42d7f56f6e7834fb9","names":[],"sources":[]}, {"digest":"0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3","names":[],"sources":[]}, {"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52","names":[],"sources":[]}, @@ -113,6 +113,7 @@ {"digest":"12a0cfa619ac4a5fc6c3e8e359c8dae15769edc61497a575935c78d5842f8e42","names":[],"sources":[]}, {"digest":"12d5808fac1996b9b3c2afa806e03022252ba2d7b88f835862ae1896591f60c7","names":["packages/llm/llm/src/types.ts#ImageBlock"],"sources":["packages/llm/llm/src/types.ts:86"]}, {"digest":"1379f0b72275994d20f1ddca2d60a96ab16ec80397ca9f1af21bdc67ba5c269c","names":[],"sources":[]}, + {"digest":"137ac60e26bcb192210d9a32a704b73b0924ed34019323defa1b787be95bd45e","names":[],"sources":[]}, {"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176","names":[],"sources":[]}, {"digest":"14230f9b8b5797bb7e8c621fa636531cfbde2ffba1ef5f1fd058216ff21952a0","names":[],"sources":[]}, {"digest":"142c37d302aa22a0a57915b5255939d3ca91c1811ca13fddcf84cf76ea00758a","names":[],"sources":[]}, @@ -121,6 +122,7 @@ {"digest":"15495614ccfa9cbdeefb0ec2abd76406435f4c6ff3dae42acae8e09ef42025c9","names":[],"sources":[]}, {"digest":"1571525adc95a24e5d25b5a6440df401cd7cdd881b745178e85f5a333435cd1b","names":[],"sources":[]}, {"digest":"15d5dfdd822aa35e115afd74a8982825a493880457774e6850bc1520b50875e4","names":[],"sources":[]}, + {"digest":"1682631f010d5ea717d181ed6f4ef46cde08d92a31ab461e7e98edfc98461f85","names":[],"sources":[]}, {"digest":"16d0507cb94b4cfc84a87446b1ba7652d6ec1d65ee1684789e747e30d542800e","names":[],"sources":[]}, {"digest":"17aae616ef020b8b9f011ec398ec935450fb06d3e2a0722c456f63aeaf5ff728","names":[],"sources":[]}, {"digest":"17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3","names":[],"sources":["packages/core/session/src/types.ts:435"]}, @@ -155,7 +157,7 @@ {"digest":"1fdad624334d354820dfb3a26e28d0bc9fc1b62a33f87bab63f715b3f4391bfd","names":[],"sources":[]}, {"digest":"20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5","names":[],"sources":["packages/plan/plan-mode/src/index.ts:53"]}, {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, - {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/team/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/team/agent-team/src/types.ts:117"]}, + {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/team/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/team/agent-team/src/types.ts:121"]}, {"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d","names":[],"sources":[]}, {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:29"]}, {"digest":"224aa11d4d5f88c450822d01b99e6ca5e3a9c7f26a5ede10bae33384a09ef4dc","names":[],"sources":[]}, @@ -174,6 +176,7 @@ {"digest":"287211846beea1eccb5f9acee00d20388a38fcb2e95858fa2332d6f58529379a","names":[],"sources":["packages/llm/llm/src/types.ts:165"]}, {"digest":"28936c614bd4e7e2774806edcbb3cbfd3ee643ac1cbf0bbdff43d915505a8fe3","names":[],"sources":[]}, {"digest":"290f32bfd81ff85a8095886a8066fae06e885942b63a8bcd6a8d66d328445808","names":[],"sources":[]}, + {"digest":"295809283ede248d14e6a431dec1c464cfd80a54b0d8f2a5994219fd8c8f9998","names":[],"sources":["packages/core/session/src/types.ts:317"]}, {"digest":"297fbd8067d7815adae02b89790ec6ff5fb7c2cb0d0eb026e1746c24ec857761","names":[],"sources":[]}, {"digest":"2aad1179f957631477b582e57cd8f538cb82618fc3fb816bde1171154235b065","names":[],"sources":[]}, {"digest":"2ab12a0e345658f9342816d773fe610f049982bcad82631f43b9205abe43e5ee","names":[],"sources":[]}, @@ -185,7 +188,7 @@ {"digest":"2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863","names":[],"sources":[]}, {"digest":"2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f","names":["packages/schedule/schedule/src/types.ts#ScheduleChange"],"sources":["packages/schedule/schedule/src/types.ts:232"]}, {"digest":"2e4debca12f2c449f96dc6574f3fbf2fb346193443114a241d01a236362ec48c","names":[],"sources":[]}, - {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/team/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/team/agent-team/src/types.ts:108"]}, + {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/team/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/team/agent-team/src/types.ts:112"]}, {"digest":"2f1e6a7ebace43d727ae138ddcd352eb3813960009e5805b8c5ac84300c97716","names":["packages/attachment/attachment/src/types.ts#ImageMediaType"],"sources":["packages/attachment/attachment/src/types.ts:8"]}, {"digest":"2f3f201c8846e19c20bb75c02280e3d4eb3c57509e4715f0c0a4d1d4f91a7a06","names":[],"sources":[]}, {"digest":"2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchStartEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:12"]}, @@ -214,6 +217,7 @@ {"digest":"3b1be838223869fe0a08210db85bf773796ed3f2373ac16555dff227cade0c48","names":[],"sources":[]}, {"digest":"3b4f2c9ef2c1217eac596c7b070173cb7896e2fae09e729bc7c56c801e56bb29","names":[],"sources":[]}, {"digest":"3bc287e765d58dc3a22672a0495a6cfd561220dd38fbaecef80aa95b96caed79","names":[],"sources":[]}, + {"digest":"3bca25b2baa6b22be9b577621a7603497f40870ed88a8dac4383d37dc2bb6e18","names":[],"sources":[]}, {"digest":"3bd652ebfa8726b3ce3937a4f1bd2759e02f86b3a08a20f9e41a8513656fbc5f","names":["packages/core/session/src/types.ts#OptionalSessionSeq"],"sources":["packages/core/session/src/types.ts:66"]}, {"digest":"3bdb8dc6989655b0d4ec361edafb8e2629bc263e1ea591819414fdafc1a413b3","names":[],"sources":[]}, {"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:497"]}, @@ -227,9 +231,11 @@ {"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796","names":[],"sources":[]}, {"digest":"3f74ba74acd10644c1ed6947a411d8088bd2cfac74f61bb8540f96d68e31f004","names":[],"sources":[]}, {"digest":"3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f","names":["packages/schedule/schedule/src/types.ts#LegacyAfterScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:161"]}, + {"digest":"4018a00144474b6c60dc1aa6e5c1f03611c66bd739833779a7d20a511df6d082","names":["packages/team/agent-team/src/types.ts#TeamTaskSnapshot"],"sources":["packages/team/agent-team/src/types.ts:64"]}, {"digest":"408927267422a7b2beb6df354ff7c1406d929a5646554f48adf85eb8372f811c","names":[],"sources":[]}, {"digest":"4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41","names":[],"sources":[]}, {"digest":"42e0916e0dda5f6d1e7bb05d8514717147c036a79c9f36085683469516c1fd3f","names":[],"sources":[]}, + {"digest":"42e2ff61928fb7dfaf0f057d61686f3a32ff713da419d12d4f250efc96733fe0","names":[],"sources":[]}, {"digest":"4312134fc6f9cad507c31390bd3d147535492dd77621ec517c5e85f853d53a03","names":[],"sources":[]}, {"digest":"43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8","names":["packages/llm/llm/src/types.ts#ToolRemovalBlock"],"sources":["packages/llm/llm/src/types.ts:134"]}, {"digest":"44818000e56acb97afdb5045ce235eb55e3fb1ccabc9fc7d0750bb38f36a7f8e","names":[],"sources":[]}, @@ -253,7 +259,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/team/agent-team/src/types.ts#TeamId","packages/team/agent-team/src/types.ts#TeamMessageId","packages/team/agent-team/src/types.ts#TeamTaskId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:389","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/team/agent-team/src/types.ts:20","packages/team/agent-team/src/types.ts:32","packages/team/agent-team/src/types.ts:8","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/team/agent-team/src/types.ts#TeamId","packages/team/agent-team/src/types.ts#TeamMessageId","packages/team/agent-team/src/types.ts#TeamTaskId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:391","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/team/agent-team/src/types.ts:20","packages/team/agent-team/src/types.ts:32","packages/team/agent-team/src/types.ts:8","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b","names":["packages/experimental/liveshare/src/host/viewer-principal.ts#ShareViewerPrincipal"],"sources":["packages/experimental/liveshare/src/host/viewer-principal.ts:24"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, @@ -262,16 +268,16 @@ {"digest":"4f507598cf476214aa7646ce7841a7064a9dcb05d0a1eaaefaf79c2b738f160f","names":[],"sources":[]}, {"digest":"4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4","names":[],"sources":["packages/context/session-reference/src/types.ts:18"]}, {"digest":"4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc","names":[],"sources":[]}, + {"digest":"50a15408d511e9be3fa93abff37a38ae770f3ab905fc6849fee0d989200d2b97","names":[],"sources":[]}, {"digest":"50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48","names":[],"sources":[]}, + {"digest":"510826cf4e1f9220d75c1efd90b349dd9a77fc5ba15d19e16f87e85a48db5f99","names":[],"sources":[]}, {"digest":"511801b0a6470d98566e8385d3dffddb2b67f79fcf73ed6056f46c3cc0a245a7","names":[],"sources":[]}, {"digest":"516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415","names":[],"sources":[]}, - {"digest":"51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357","names":["packages/team/agent-team/src/types.ts#TeamTaskSnapshot"],"sources":["packages/team/agent-team/src/types.ts:64"]}, {"digest":"525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336","names":[],"sources":[]}, {"digest":"529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05","names":[],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734","names":[],"sources":[]}, {"digest":"5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777","names":[],"sources":["packages/core/session/src/types.ts:383"]}, {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:279"]}, - {"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac","names":[],"sources":[]}, {"digest":"53cf1704ae0709c7678bcf070c83c619fc48ca1b5c173b74cfa8702f22a8e492","names":[],"sources":[]}, {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:363"]}, {"digest":"557e148d30b79ee48645850832b30c717d7c3b64641deb5d6133b8b4e91ee0b8","names":[],"sources":[]}, @@ -305,17 +311,15 @@ {"digest":"5f2117d1856a5ab43c28371faa978ebfaa48f8c7d734ea3fc94842210df83dca","names":["packages/llm/llm/src/types.ts#LlmFailure"],"sources":["packages/llm/llm/src/types.ts:48"]}, {"digest":"5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627","names":[],"sources":[]}, {"digest":"5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564","names":["packages/subagent/subagent/src/descriptor.ts#OneShotSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:61"]}, - {"digest":"601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:43"]}, {"digest":"6085488d223aeffdef51d7ae17655fc26ab22ee13cbed82a50e62e2b6fb5b918","names":[],"sources":[]}, {"digest":"611cae9a3539733aa0c42f66035a7d90f19dcbbdfb5d9e3b628b13705253942d","names":[],"sources":[]}, {"digest":"611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da","names":["packages/jobs/jobs/src/types.ts#JobSettledData"],"sources":["packages/jobs/jobs/src/types.ts:40"]}, - {"digest":"613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"616f93a5047de668e1c30d57b1786abd76beeb4123599637ec1dc5290320ede5","names":[],"sources":[]}, {"digest":"6292bcbedaa97feaec5b48a03bb8144b581b93309d9d6c905f26da7a6a7a0a4f","names":[],"sources":[]}, {"digest":"62e6a429ff3f390a46ac39fb468692032f42936ca0db19b53454184189401379","names":[],"sources":[]}, {"digest":"62ecb62dc0fe1b5e70217f8a80c39256a40ee3a1917be14e5ca899d20c35f7fd","names":[],"sources":[]}, + {"digest":"63ba8895cc09e4693cf0819fdcd18c084bef195febda5b6a23af279b3852be36","names":[],"sources":[]}, {"digest":"6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f","names":["packages/subagent/subagent/src/descriptor.ts#ContinuableSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:72"]}, - {"digest":"65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e","names":[],"sources":["packages/core/session/src/types.ts:317"]}, {"digest":"6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25","names":["packages/team/agent-team/src/types.ts#TeamMemberSnapshot"],"sources":["packages/team/agent-team/src/types.ts:50"]}, {"digest":"65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad","names":["packages/llm/llm/src/message.ts#SystemPromptMessageSource"],"sources":["packages/llm/llm/src/message.ts:35"]}, {"digest":"65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099","names":["packages/skill/tool-skill/src/index.ts#SkillCatalogSource"],"sources":["packages/skill/tool-skill/src/index.ts:35"]}, @@ -325,7 +329,6 @@ {"digest":"68141cbe3994a5ffcd064c07c75996538437fd9dbf77ce8a2927e89ec577fabe","names":[],"sources":[]}, {"digest":"688e853dc149f0d30b31765c12ec7e3be74169bdf1a4c6463a10b945d50cc1b8","names":[],"sources":[]}, {"digest":"68f44f59e91c41395076cb77265981a601680ec8a381bbbeb6ab754ffdc94573","names":[],"sources":[]}, - {"digest":"690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0","names":[],"sources":[]}, {"digest":"69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1","names":[],"sources":[]}, {"digest":"693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057","names":[],"sources":["packages/llm/llm/src/types.ts:168"]}, {"digest":"69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199","names":[],"sources":[]}, @@ -350,12 +353,12 @@ {"digest":"6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20","names":["packages/llm/llm-retry/src/types.ts#LlmRetryStartedEventData"],"sources":["packages/llm/llm-retry/src/types.ts:43"]}, {"digest":"6f9e00383836275e46c0a9915767f9bb561be5fc30aae490174908a93e99a912","names":[],"sources":[]}, {"digest":"70b082fe78991edf13cf66e1b4d06cd2dcff9cacfd76f8cfb996bfb61939e7b2","names":[],"sources":[]}, - {"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a","names":[],"sources":[]}, {"digest":"7180844e1066230c5e891f1b9ccd942423b0fe48aac6ded7eef2a285b288a732","names":[],"sources":[]}, {"digest":"719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91","names":["packages/llm/llm/src/message.ts#ModelMessageSource"],"sources":["packages/llm/llm/src/message.ts:24"]}, {"digest":"71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:28"]}, {"digest":"72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227","names":[],"sources":[]}, {"digest":"7382c4fc95fe8c5db65ca4d9da572b115d0dbb818b7f32c22ab040318ffbb4bc","names":[],"sources":[]}, + {"digest":"74e0e5885d2ddf04530ddf341b25cd65e1f85c00b30a88bd992f11fc06615a0c","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5","names":["packages/jobs/jobs/src/types.ts#JobSettleCause"],"sources":["packages/jobs/jobs/src/types.ts:247"]}, {"digest":"7554565be416b0342053ee6a46256c9dab07c487349b4a5f10bab1edc151c619","names":[],"sources":[]}, {"digest":"756b5e23a5cb2c7f486f366a828f7461c88902af21125bd6810881f4ccbff6cc","names":[],"sources":[]}, @@ -386,6 +389,7 @@ {"digest":"814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e","names":["packages/llm/llm/src/types.ts#ReasoningBlock"],"sources":["packages/llm/llm/src/types.ts:75"]}, {"digest":"82470020b5bfcb1a843e2aa05602af7d37df99e82d01f015c7c589ba054fd89d","names":[],"sources":[]}, {"digest":"824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce","names":[],"sources":[]}, + {"digest":"824b1d4d4f007bdaa5eee7994a5db3ede7707c6cd2bf8e2509be176c7609e3bc","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, {"digest":"825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8","names":[],"sources":[]}, {"digest":"829a2fb3be6efd4ba36aef8ed2d381a4c6fb2a0aaf50c823a6f3f8290786b809","names":[],"sources":[]}, {"digest":"83079c8a3f733ac3fa603eefa0fbb4737ca4d8b69e7e2e7298b60f9ac4098693","names":[],"sources":["packages/skill/tool-skill/src/index.ts:43"]}, @@ -409,6 +413,7 @@ {"digest":"88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa","names":[],"sources":[]}, {"digest":"88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a","names":[],"sources":["packages/llm/llm/src/types.ts:500"]}, {"digest":"8a046b52331959aa3394680d6acbe09b8a5447c6b61375f263445bfcef19b7f1","names":[],"sources":[]}, + {"digest":"8a54d228b1b7d8453e4304d32723d489f28b4b33b221f335342146926ad94fc6","names":[],"sources":[]}, {"digest":"8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0","names":[],"sources":[]}, {"digest":"8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad","names":[],"sources":["packages/interaction/permission-presets/src/index.ts:53"]}, {"digest":"8ad45ad053b4d89f8865c17cc9ff3b8748e86045f3f0fb816269a34af2619141","names":[],"sources":["packages/experimental/hook-protocol/src/types.ts:31"]}, @@ -421,8 +426,10 @@ {"digest":"8e611bf46821e5003a1605a73560a6f56388295149d449dfe2142d5ec335f29c","names":["packages/llm/llm/src/types.ts#ToolAdditionBlock"],"sources":["packages/llm/llm/src/types.ts:122"]}, {"digest":"8e8155594e07812356b67d732196dda894b0f9c91a5969d51612cf490b0a016d","names":[],"sources":[]}, {"digest":"8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af","names":["packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:213"]}, + {"digest":"8f1062573ea9184129dfdec43df1e8136eb5808746fd809bf6b539f3fd11362e","names":[],"sources":["packages/api/session-controller/src/types.ts:402"]}, {"digest":"8f324cea3916c0517261e88167def8efb3a9994c1135e23d40ed9fd48f8c7bd4","names":[],"sources":[]}, {"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]}, + {"digest":"8faed5b52ab2d2df40b94a926d0f0faa75280bddcc4b17b6bdd9c8bfed7c34b5","names":[],"sources":[]}, {"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:28"]}, {"digest":"904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd","names":[],"sources":[]}, {"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75","names":[],"sources":[]}, @@ -435,7 +442,6 @@ {"digest":"94c56aeb1564ad735eb773d88636b40761d063efa1610f1308004d32923f5378","names":[],"sources":[]}, {"digest":"95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65","names":[],"sources":["packages/llm/llm/src/types.ts:498"]}, {"digest":"95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd","names":[],"sources":[]}, - {"digest":"95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70","names":[],"sources":[]}, {"digest":"95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60","names":[],"sources":[]}, {"digest":"9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114","names":[],"sources":[]}, {"digest":"96bfb916a727cd4445a594df264c523fc039749a31bfed924b8cc7bb308a5bb0","names":[],"sources":[]}, @@ -473,8 +479,8 @@ {"digest":"a3a259a1aa5401af786ce08c4a8419681d3000d73467991b5fc563389bc4ebd2","names":[],"sources":[]}, {"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]}, {"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]}, - {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/team/agent-team/src/types.ts:177"]}, - {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:398"]}, + {"digest":"a43327a1739c3fa477f32981071fef7d8ba6ae5f32a84fd70f547b2eee59e93e","names":[],"sources":[]}, + {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:400"]}, {"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]}, {"digest":"a6b41f5bede8094d7bf635be5d69b1bd7a25e006ca9cf9415bceb9322f1a7feb","names":["packages/experimental/hook-protocol/src/types.ts#HookDialect"],"sources":["packages/experimental/hook-protocol/src/types.ts:48"]}, {"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]}, @@ -485,6 +491,7 @@ {"digest":"a8a2b9011fc067862df7ce40748868467e5778283d7ac193c13f77684a914c57","names":[],"sources":[]}, {"digest":"a9059a81430b320792bbb7c2ee1f8f6c77ff71e40ee44c18aec72e157c522694","names":[],"sources":[]}, {"digest":"a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb","names":["packages/schedule/schedule/src/types.ts#LegacyEveryScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:173"]}, + {"digest":"a9427866376b691aace0ce25aa0e1bdec2a14156a05152947a467da3f7967cb9","names":[],"sources":["packages/team/agent-team/src/types.ts:181"]}, {"digest":"a99ec0993c62c058e50af9f68922202a3b54f5e259bc2abf1915a73381d2316d","names":[],"sources":[]}, {"digest":"a99f76fd149051c363f960bfeeb5b9509587ecaf7591a42797e4bfe810d8987a","names":["packages/todo/tool-todo/src/types.ts#TodoItem"],"sources":["packages/todo/tool-todo/src/types.ts:23"]}, {"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8","names":[],"sources":[]}, @@ -510,10 +517,9 @@ {"digest":"b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d","names":[],"sources":[]}, {"digest":"b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1","names":[],"sources":[]}, {"digest":"b0b0eb1b16490f6bee135b2377e8f8c39227974c799ad7d49ff418af23ecbc35","names":["packages/attachment/attachment/src/types.ts#ImageAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:11"]}, - {"digest":"b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:260"]}, {"digest":"b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7","names":[],"sources":[]}, - {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/team/agent-team/src/types.ts:175"]}, + {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/team/agent-team/src/types.ts:179"]}, {"digest":"b1f62380f6746fedd629e71065e3583cef83334875b499ac9665c2eb87acfbf5","names":[],"sources":[]}, {"digest":"b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e","names":[],"sources":[]}, {"digest":"b2245a906e13aa94f2d3cc6cf7b04cfa0267f7b7f4d468de1b00b1a314e54626","names":[],"sources":[]}, @@ -553,18 +559,21 @@ {"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3","names":[],"sources":[]}, {"digest":"bb94f3217aa56c8876bbff0b8460d6a73cbead545659abb29e76cd0cf2cb92d6","names":[],"sources":[]}, {"digest":"bbc5696e3f13c758237b316e3cf08cda0b9fd51ffe672539883457769faa185d","names":[],"sources":[]}, + {"digest":"bca0f9cb63d8620b6954bb045b07bb07cb557513a7f0e2e177f5e87f704e7ba7","names":[],"sources":[]}, {"digest":"bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825","names":[],"sources":["packages/core/session/src/types.ts:472"]}, {"digest":"bd499eac047efe091a4df7382eadd06d25cf0cb31fee3981e8b24d85762841cd","names":[],"sources":[]}, + {"digest":"bec5ca3a376f56f3d69df7f12a48e1cc07d4ea8305968773394c8734799faeb4","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:43"]}, {"digest":"bf16d2e98a7f13894ded2cffe5ce857bf473954a0e3c0ed9bc7739e2e1c7b78a","names":[],"sources":[]}, {"digest":"c0ea97bc89dfc46fa94eab956f5c3ee6af2f964aa7db6327cb18b451f207bef7","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowAgentEndData"],"sources":["packages/workflow/tool-workflow/src/types.ts:29"]}, {"digest":"c170da8ab7c37635b10867b2571811d9e317ac3ec394e8ff8efe53f902fc41c1","names":["packages/llm/llm-retry/src/types.ts#LlmRetryEventData"],"sources":["packages/llm/llm-retry/src/types.ts:16"]}, {"digest":"c1f0f17b6493b85db6453bb30e01b208d703731b25bc1e68e5d4fc72eea9cdea","names":["packages/llm/llm/src/types.ts#ToolCallBlock"],"sources":["packages/llm/llm/src/types.ts:112"]}, {"digest":"c1f9e0405de6d18cabb9ee70782a027f9bbdc57e5abec9dcccdd56119e2e9058","names":[],"sources":[]}, {"digest":"c219efbdfd53b836786202ab4532913d555798f0a8615a4e9629736e3799fb70","names":[],"sources":[]}, - {"digest":"c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434","names":[],"sources":["packages/team/agent-team/src/types.ts:173"]}, + {"digest":"c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434","names":[],"sources":["packages/team/agent-team/src/types.ts:177"]}, {"digest":"c2deb7c9183735d4e0fffddf5c623f4e34daa1c8e96b111982e957dfcbbcf896","names":["packages/workflow/workflow/src/types.ts#WorkflowAgentOutcome"],"sources":["packages/workflow/workflow/src/types.ts:110"]}, {"digest":"c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3","names":[],"sources":[]}, {"digest":"c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943","names":[],"sources":[]}, + {"digest":"c51f0d0a405bb96fcb008c0dc560a512dcd8c3919b6b446937f449ea2f7d21d8","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49","names":[],"sources":[]}, {"digest":"c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005","names":[],"sources":["packages/interaction/user-questions/src/types.ts:134"]}, {"digest":"c5687b1313206a6ff334066047b6a0681611467dc4df8c5a8e71d0b6df39311f","names":[],"sources":[]}, @@ -586,17 +595,16 @@ {"digest":"cffcdc19881ab61ec0a9ce18743996f7ac44ac8889f34dfac36ae745574aa22f","names":["packages/goal/goal/src/domain.ts#GoalClearChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:35"]}, {"digest":"d0fbb0ca1bbba059830826f33f801da1aced35a2504376d61e020bdfec8d50c0","names":[],"sources":[]}, {"digest":"d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2","names":[],"sources":[]}, + {"digest":"d49164d37eab0f0f70fa787c65d887abbea869c010ccb103d717fab3519c3a41","names":[],"sources":[]}, {"digest":"d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d","names":[],"sources":["packages/llm/llm/src/types.ts:501"]}, {"digest":"d4c799e3d51cae1040362ba4968efc484739e14416c9c1df20c2f108b4ef2eb5","names":[],"sources":[]}, {"digest":"d4e4d575abeb4b72d616dc65c17ac0eaa943cc135609395044c38d5afcb10e31","names":[],"sources":[]}, - {"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a","names":[],"sources":[]}, {"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9","names":[],"sources":[]}, {"digest":"d66e11dddf5dfa3c71547e2cbd7b0608824cd8d6243fc16f532017ff28e8fba5","names":[],"sources":[]}, {"digest":"d6e654b88b584fcdf290a56802b1730bc7b9847ae86baa4265ab7769eb48990c","names":[],"sources":[]}, {"digest":"d85590a5675c6e2fd8164e5792a6cceac714605b8ac228adae5ee668facb1490","names":[],"sources":[]}, {"digest":"d8a476d2c6fd30df6ec0e799f91716d1518c780d23b4121dce545abfcfb7c5a5","names":[],"sources":[]}, {"digest":"d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241","names":[],"sources":[]}, - {"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca","names":[],"sources":[]}, {"digest":"d9c462b9f62fdd3efc76a44b9524dc8781f6b91cd9c7e584b17d971defafebab","names":["packages/llm/llm/src/message.ts#ToolMessageSource"],"sources":["packages/llm/llm/src/message.ts:29"]}, {"digest":"d9edf1862e4471d6ad8a106a3f4160e0643874368707d50ecd8e27e5ee7a2567","names":[],"sources":[]}, {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:36"]}, @@ -606,12 +614,11 @@ {"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965","names":[],"sources":[]}, {"digest":"dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd","names":[],"sources":["packages/core/tool-executor-ptc/src/types.ts:68"]}, {"digest":"dbdafceb9a900183cccc80596498d386f3bf7bddead2b8cfde68453aa4739edc","names":[],"sources":[]}, - {"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450","names":[],"sources":[]}, + {"digest":"dca3ba43188095fa7342e49e6c206d4685125497d4767595bf12a62ea2fd73ce","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a","names":[],"sources":[]}, {"digest":"dd2780e7e07684332f7a9ab047237798d260e7eaffb1321cb38eca695d184c3d","names":[],"sources":[]}, {"digest":"dd5ee8762bd8a14b6c6bffedd1753e4e78505bb0f9ea1621018d03a54eef59d5","names":[],"sources":[]}, {"digest":"dd6cc2bb2a8f28248347021ad93565360b196a042360b4cd16b569d3a2a91f2d","names":[],"sources":[]}, - {"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482","names":[],"sources":[]}, {"digest":"dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0","names":[],"sources":[]}, {"digest":"de25352d2dc4196309bd84b5bac6319f6d5c898ea0e82bb5ae66c5738d0d426f","names":[],"sources":[]}, {"digest":"de30b1fa12c75f81df6aa4096e1595296d2d04e2be14a7c29849cf8028b79048","names":[],"sources":[]}, @@ -621,8 +628,6 @@ {"digest":"df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18","names":[],"sources":[]}, {"digest":"dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd","names":["packages/team/agent-team/src/types.ts#TeamMemberPhase"],"sources":["packages/team/agent-team/src/types.ts:47"]}, {"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05","names":[],"sources":[]}, - {"digest":"e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, - {"digest":"e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187","names":[],"sources":[]}, {"digest":"e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa","names":[],"sources":[]}, {"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602","names":[],"sources":[]}, @@ -647,7 +652,7 @@ {"digest":"e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864","names":[],"sources":[]}, {"digest":"e8de0f457fed5b9ff94dc4950b59ef75fd8ba8af665cc7899485924a62653367","names":[],"sources":[]}, {"digest":"e95f957154b2b1b58692af53d4131180b6dd19e2264869f876dbaa959991494e","names":[],"sources":[]}, - {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/team/agent-team/src/types.ts:179"]}, + {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/team/agent-team/src/types.ts:183"]}, {"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:201"]}, {"digest":"ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8","names":[],"sources":[]}, {"digest":"ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93","names":[],"sources":[]}, @@ -680,7 +685,7 @@ {"digest":"f6afe41e3926f422fe849debe90819fddd739f0f59e20be9c71018b9554a7f09","names":[],"sources":[]}, {"digest":"f6cb462f808309e315fea634f1ed86268b8346bbcee6774d9097ca6eed7ac1f8","names":[],"sources":[]}, {"digest":"f7283223aacdc39b0adca728c8cbd4c090a20006a6fecec74421c30e943b489c","names":[],"sources":[]}, - {"digest":"f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87","names":["packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent"],"sources":["packages/subagent/subagent/src/catalog.ts:28"]}, + {"digest":"f7752f518bb2f9165f2c01559df1f570c23d57373defbbd4512399a0ffaf2492","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"f789af5ab844827eb5a4f357c0afae1eeed108c6c22d8ca1a56e586b349a7c85","names":[],"sources":[]}, {"digest":"f88f1b7b6bb733f685f5fb6a8744a5db9199fa53c90463ea1a33bad51153671d","names":[],"sources":[]}, {"digest":"f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d","names":[],"sources":["packages/core/session/src/types.ts:294","packages/deliverables/workspace-changes/src/types.ts:106"]}, @@ -703,13 +708,13 @@ ], "nodes": { "0116b1f89c05221d": {"kind":"literal","value":"web_search_20250305"}, - "01c161379a3c3394": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "023a355b2be0d688": {"kind":"object","properties":[{"name":"kind","type":"254dce209e735f9c","optional":false}],"indices":[]}, "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]}, "0341d4c3dfcd5325": {"kind":"literal","value":"product-interaction"}, "05205074b24d8340": {"kind":"literal","value":"action-cards"}, + "058fc9a6b4fbd5d4": {"kind":"union","types":["0e10d7c096b47e61","5d542ac7bdf13310","8faed5b52ab2d2df","a0f25b5aeeebf2bf","a43327a1739c3fa4","b6896938660a1dfe","cbbe8377ee744964","e2ad8f5bcd813f26"]}, "05a2796621b1ee6c": {"kind":"literal","value":"unknown"}, "05d2ae1e0c7f8770": {"kind":"union","types":["1571525adc95a24e","9e07693fd7cd9bab"]}, "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, @@ -746,6 +751,7 @@ "12a0cfa619ac4a5f": {"kind":"literal","value":"read-only"}, "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]}, "1379f0b72275994d": {"kind":"literal","value":"developer/message"}, + "137ac60e26bcb192": {"kind":"literal","value":3}, "13d3d180f977bf78": {"kind":"object","properties":[{"name":"data","type":"1528539c63db8b23","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1a2718b00937b236","optional":false}],"indices":[]}, "14230f9b8b5797bb": {"kind":"literal","value":"chunk"}, "142c37d302aa22a0": {"kind":"literal","value":"always"}, @@ -754,6 +760,7 @@ "15495614ccfa9cbd": {"kind":"literal","value":"codex"}, "1571525adc95a24e": {"kind":"literal","value":"never"}, "15d5dfdd822aa35e": {"kind":"object","properties":[{"name":"data","type":"54f9276a1617d7c1","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"e95f957154b2b1b5","optional":false}],"indices":[]}, + "1682631f010d5ea7": {"kind":"object","properties":[{"name":"data","type":"f7752f518bb2f916","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "16d0507cb94b4cfc": {"kind":"literal","value":"relay"}, "17aae616ef020b8b": {"kind":"literal","value":"at"}, "17d1afb770d99419": {"kind":"object","properties":[{"name":"inherited","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, @@ -807,6 +814,7 @@ "287211846beea1ec": {"kind":"object","properties":[{"name":"kind","type":"6c1cccb433ddc153","optional":false}],"indices":[]}, "28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "295809283ede248d": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"824b1d4d4f007bda","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"}, @@ -847,6 +855,7 @@ "3b1be838223869fe": {"kind":"object","properties":[{"name":"data","type":"e4c18e294232c3ba","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"c589e938cc0e4989","optional":false}],"indices":[]}, "3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, "3bc287e765d58dc3": {"kind":"literal","value":"normal"}, + "3bca25b2baa6b22b": {"kind":"object","properties":[{"name":"data","type":"bec5ca3a376f56f3","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]}, "3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "3bec87f9e6ab3da0": {"kind":"object","properties":[{"name":"blockType","type":"7d65acf0a3a43a8f","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"cbca49bde16b656d","optional":false}],"indices":[]}, @@ -860,9 +869,11 @@ "3ee93b06f3a12585": {"kind":"object","properties":[{"name":"data","type":"7c6129b6961cf4b2","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44818000e56acb97","optional":false}],"indices":[]}, "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, "3fdf9582c3b5597d": {"kind":"object","properties":[{"name":"afterSeconds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"5b232a9b9bfcc0fc","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "4018a00144474b6c": {"kind":"object","properties":[{"name":"blockedBy","type":"93c33d9687613293","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"ownerId","type":"4cd48f3e5108bd6e","optional":true},{"name":"ownerName","type":"4cd48f3e5108bd6e","optional":true},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"status","type":"b2d2a01e51341a3b","optional":false},{"name":"subject","type":"4cd48f3e5108bd6e","optional":false},{"name":"writeScopes","type":"93c33d9687613293","optional":false}],"indices":[]}, "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, "4208123b50df5006": {"kind":"object","properties":[{"name":"data","type":"ad0c38a0adb82a94","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"3e0eff5946255ff0","optional":false}],"indices":[]}, "42e0916e0dda5f6d": {"kind":"object","properties":[{"name":"data","type":"7f9365bae9fcb73e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fe792a6cd4304945","optional":false}],"indices":[]}, + "42e2ff61928fb7df": {"kind":"array","element":"c51f0d0a405bb96f"}, "4312134fc6f9cad5": {"kind":"literal","value":"turn/end"}, "43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]}, "44818000e56acb97": {"kind":"literal","value":"feedback/message-delete"}, @@ -895,16 +906,16 @@ "4f507598cf476214": {"kind":"literal","value":"series"}, "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, "4fb59762612c3e3a": {"kind":"object","properties":[{"name":"data","type":"b1ebd5cda47d90d9","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"18fcdd273a2f2f9d","optional":false}],"indices":[]}, + "50a15408d511e9be": {"kind":"object","properties":[{"name":"data","type":"74e0e5885d2ddf04","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "50cf02d875f8a1f1": {"kind":"union","types":["2da725e554cd8930","dd2780e7e0768433"]}, + "510826cf4e1f9220": {"kind":"object","properties":[{"name":"data","type":"295809283ede248d","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "511801b0a6470d98": {"kind":"literal","value":"success"}, "516da4cdd6d2f1e5": {"kind":"object","properties":[{"name":"data","type":"9890113e97dd3541","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ca085dba2c877fec","optional":false}],"indices":[]}, - "51edec100659da3a": {"kind":"object","properties":[{"name":"blockedBy","type":"93c33d9687613293","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"ownerId","type":"4cd48f3e5108bd6e","optional":true},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"status","type":"b2d2a01e51341a3b","optional":false},{"name":"subject","type":"4cd48f3e5108bd6e","optional":false},{"name":"writeScopes","type":"93c33d9687613293","optional":false}],"indices":[]}, "525254db03b1d1e6": {"kind":"object","properties":[{"name":"data","type":"c170da8ab7c37635","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"611cae9a3539733a","optional":false}],"indices":[]}, "529e0ccd0e34079a": {"kind":"object","properties":[],"indices":[{"key":"4cd48f3e5108bd6e","value":"c592ce75aab73fca"}]}, "52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "5302e76315127761": {"kind":"object","properties":[{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"message","type":"4b259f7f8f0a44ad","optional":false},{"name":"meta","type":"c592ce75aab73fca","optional":true},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "530da6c0267813bb": {"kind":"union","types":["34d1ab5c5df37818","4f507598cf476214","e7ce49f12953583f","f789af5ab844827e"]}, - "53750b9abf61efb7": {"kind":"object","properties":[{"name":"data","type":"f731ffe80810b028","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44c8c77aa9aa100b","optional":false}],"indices":[]}, "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, "54f9276a1617d7c1": {"kind":"object","properties":[{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"stream","type":"84bbfdce7d2eab5b","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "557e148d30b79ee4": {"kind":"literal","value":"agent-preset/selected"}, @@ -938,17 +949,15 @@ "5f2117d1856a5ab4": {"kind":"object","properties":[{"name":"code","type":"4cd48f3e5108bd6e","optional":false},{"name":"message","type":"4cd48f3e5108bd6e","optional":false},{"name":"offloadImages","type":"0657d3ca5ac4e7bf","optional":true},{"name":"providerRetryAfterMs","type":"0657d3ca5ac4e7bf","optional":true},{"name":"requestId","type":"4cd48f3e5108bd6e","optional":true},{"name":"status","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]}, "5f26a6c20b37632f": {"kind":"object","properties":[{"name":"data","type":"9a410ddf56792b2b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ad92f473a03ae725","optional":false}],"indices":[]}, "5f758f3de5f00c18": {"kind":"object","properties":[{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "601327bedca5f98a": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"95b5cf90ea1f57da","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, "611cae9a3539733a": {"kind":"literal","value":"llm/retry"}, "611d82d2dc50df70": {"kind":"object","properties":[{"name":"cause","type":"750a2b129d67704d","optional":false},{"name":"detail","type":"4cd48f3e5108bd6e","optional":true},{"name":"finishedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"jobId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"owner","type":"4cd48f3e5108bd6e","optional":false},{"name":"producerReported","type":"aa93010d0e521ad7","optional":false},{"name":"startedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"status","type":"c2f49004d9ae38a9","optional":false},{"name":"toolCall","type":"39cf2d3ff8bac81c","optional":true}],"indices":[]}, - "613660455704fb82": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "616f93a5047de668": {"kind":"literal","value":"tool-call-chunks"}, "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "62e6a429ff3f390a": {"kind":"union","types":["1d8798b1a5e8c545","1e346446e183ee76","34d1ab5c5df37818","7624ca28c61ca0ea","ad22a5ca5bbaab2e","d9196086e41e4dd5"]}, "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, + "63ba8895cc09e469": {"kind":"literal","value":"retry"}, "6487b10ba7542f3c": {"kind":"object","properties":[{"name":"agentModel","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentProvider","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentReasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"persona","type":"4cd48f3e5108bd6e","optional":true},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"toolFilter","type":"3cfc3a56502da1f8","optional":true},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "65377a76c6a58f78": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"01c161379a3c3394","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6598cd6da253948d": {"kind":"object","properties":[{"name":"context","type":"50cf02d875f8a1f1","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"error","type":"4cd48f3e5108bd6e","optional":true},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"phase","type":"dfce5db4aa42b7eb","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, @@ -958,7 +967,6 @@ "68141cbe3994a5ff": {"kind":"literal","value":"max-tokens"}, "688e853dc149f0d3": {"kind":"literal","value":"reasoning-chunks"}, "68f44f59e91c4139": {"kind":"literal","value":"user"}, - "690583582246babf": {"kind":"array","element":"613660455704fb82"}, "69081694be231d56": {"kind":"object","properties":[{"name":"data","type":"6bc8e51124d0874b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1c5a58f4599bd4a9","optional":false}],"indices":[]}, "693f4952fb5565da": {"kind":"object","properties":[{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"kind","type":"a2be0f0cb59f0d4a","optional":false}],"indices":[]}, "69d238a6e9b08d67": {"kind":"literal","value":"interrupted"}, @@ -983,12 +991,12 @@ "6f72ece8f317cd08": {"kind":"object","properties":[{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, "70b082fe78991edf": {"kind":"literal","value":"request/context"}, - "70ea8e7943c5e2d7": {"kind":"object","properties":[{"name":"data","type":"65377a76c6a58f78","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "7180844e1066230c": {"kind":"literal","value":"service-stability"}, "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, "71bb3254ec8f1e49": {"kind":"object","properties":[{"name":"dt","type":"5d03ba38734809bc","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"texts","type":"93c33d9687613293","optional":false},{"name":"time0","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"688e853dc149f0d3","optional":false}],"indices":[]}, "72ec79127a9c0d02": {"kind":"array","element":"c592ce75aab73fca"}, "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"}, + "74e0e5885d2ddf04": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"dca3ba43188095fa","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "750a2b129d67704d": {"kind":"union","types":["5cb63db9fe57dc09","6cf082d0fb6034de","bab6865794007548"]}, "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, @@ -1019,6 +1027,7 @@ "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]}, "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"}, "824a4aad44a54e24": {"kind":"union","types":["4d60dddaa65d3da8","deebfc7c62177ea8"]}, + "824b1d4d4f007bda": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"dca3ba43188095fa","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"}, "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1042,6 +1051,7 @@ "88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "88a610b831be6d92": {"kind":"object","properties":[{"name":"argumentsDelta","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"type","type":"9b4fd974c124c837","optional":false}],"indices":[]}, "8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "8a54d228b1b7d845": {"kind":"array","element":"74e0e5885d2ddf04"}, "8a6e1ec9e8db346b": {"kind":"object","properties":[{"name":"data","type":"119abe2b9c0b2f46","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b9305f0ea75ccd6a","optional":false}],"indices":[]}, "8a9c990773f798ea": {"kind":"object","properties":[{"name":"preset","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8ad45ad053b4d89f": {"kind":"object","properties":[{"name":"decision","type":"4cd48f3e5108bd6e","optional":false},{"name":"durationMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"exitCode","type":"0657d3ca5ac4e7bf","optional":true},{"name":"handlerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"point","type":"4cd48f3e5108bd6e","optional":false},{"name":"stderrSummary","type":"4cd48f3e5108bd6e","optional":true},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, @@ -1054,8 +1064,10 @@ "8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]}, "8e8155594e078123": {"kind":"literal","value":"workspace/changes"}, "8e8e91646a5cae3f": {"kind":"object","properties":[{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"operation","type":"26c879bdd130e8b8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "8f1062573ea91841": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"63ba8895cc09e469","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, "8f6d609794bf5afc": {"kind":"tuple","elements":[{"type":"4e88776858652856","optional":false,"rest":false}]}, + "8faed5b52ab2d2df": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"137ac60e26bcb192","optional":false}],"indices":[]}, "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, "920d87e22b7409bc": {"kind":"object","properties":[{"name":"data","type":"c2a13242efc3bea4","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d8a476d2c6fd30df","optional":false}],"indices":[]}, @@ -1068,7 +1080,6 @@ "94c56aeb1564ad73": {"kind":"literal","value":"in_progress"}, "95080295115fbd83": {"kind":"object","properties":[{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"5abab3a27fd27366","optional":false}],"indices":[]}, "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, - "95b5cf90ea1f57da": {"kind":"array","element":"e272493e3ea0b33b"}, "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, @@ -1106,7 +1117,7 @@ "a3a259a1aa5401af": {"kind":"literal","value":"hook/result"}, "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "a3edd1efd1fef21b": {"kind":"literal","value":"workspace-write"}, - "a40d12070f6f4a12": {"kind":"object","properties":[{"name":"task","type":"51edec100659da3a","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, + "a43327a1739c3fa4": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"fc351843b8ecbd05","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"137ac60e26bcb192","optional":false}],"indices":[]}, "a4afec59b75aad5d": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "a6567ccb2e530606": {"kind":"object","properties":[{"name":"data","type":"0d47a8f3d847243c","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0a8a062bc9c3c0d9","optional":false}],"indices":[]}, "a6b41f5bede8094d": {"kind":"union","types":["15495614ccfa9cbd","aff60ef947c18b01"]}, @@ -1118,6 +1129,7 @@ "a8a2b9011fc06786": {"kind":"literal","value":"team/message/delivered"}, "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"}, "a926dc71814edd01": {"kind":"object","properties":[{"name":"everySeconds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"e42b0f40b5d0b628","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "a9427866376b691a": {"kind":"object","properties":[{"name":"task","type":"4018a00144474b6c","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, "a99ec0993c62c058": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"llmStreamCall","type":"dacf3d5f3e4ff619","optional":false},{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"rawOutput","type":"b52f215a6cd69105","optional":false},{"name":"shadowedRange","type":"9e41386b3a0c9572","optional":false},{"name":"shadowedSeqs","type":"5d03ba38734809bc","optional":false},{"name":"shadowedTokenCount","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true},{"name":"summary","type":"b52f215a6cd69105","optional":false},{"name":"usage","type":"fec5442b9ee4122e","optional":true}],"indices":[]}, "a99f76fd149051c3": {"kind":"object","properties":[{"name":"content","type":"4cd48f3e5108bd6e","optional":false},{"name":"status","type":"fd0d91a1f9dd2efb","optional":false}],"indices":[]}, "aa0957eca50aeb28": {"kind":"object","properties":[{"name":"data","type":"f90eb4ab2d3897bc","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0af3ecfb8c60d416","optional":false}],"indices":[]}, @@ -1143,7 +1155,6 @@ "b0127044ab31a702": {"kind":"object","properties":[{"name":"data","type":"ca59530d1a4f3e19","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1986c2664009c991","optional":false}],"indices":[]}, "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "b120362fd216a16a": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","848639f8787f035b","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "b158f3ff69237148": {"kind":"object","properties":[{"name":"contextWindow","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"systemPromptUpdate","type":"f311671e07bc7fa6","optional":true}],"indices":[]}, "b1664fa89f8eab49": {"kind":"array","element":"bbc5696e3f13c758"}, "b1ebd5cda47d90d9": {"kind":"object","properties":[{"name":"member","type":"6598cd6da253948d","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, @@ -1186,8 +1197,10 @@ "bb1ab3d08f49a9f3": {"kind":"object","properties":[{"name":"data","type":"93cbbc5a7fd0b1aa","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"378f90f9f6022a09","optional":false}],"indices":[]}, "bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"}, "bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"}, + "bca0f9cb63d8620b": {"kind":"object","properties":[{"name":"data","type":"a9427866376b691a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b57ae1b37e52bee0","optional":false}],"indices":[]}, "bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "bd499eac047efe09": {"kind":"literal","value":"finish"}, + "bec5ca3a376f56f3": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"42e2ff61928fb7df","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"}, "c0ea97bc89dfc46f": {"kind":"object","properties":[{"name":"outcome","type":"c2deb7c9183735d4","optional":false},{"name":"runId","type":"4cd48f3e5108bd6e","optional":false},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "c170da8ab7c37635": {"kind":"union","types":["374fdd68aee5ff84","cdee10e87dbed844"]}, @@ -1198,6 +1211,7 @@ "c2deb7c9183735d4": {"kind":"union","types":["1ebc6f9ff3aa9e3a","2142e391b9ccb60d","b57a2889408de36e"]}, "c2f49004d9ae38a9": {"kind":"union","types":["1ebc6f9ff3aa9e3a","b57a2889408de36e","ef226a90ddac6cd8"]}, "c43b119ce579b3e9": {"kind":"literal","value":"paused"}, + "c51f0d0a405bb96f": {"kind":"union","types":["09f3f8dc417f2782","4b259f7f8f0a44ad","74e0e5885d2ddf04","824b1d4d4f007bda","fa87537f4a9c1e25"]}, "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]}, "c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"}, @@ -1219,17 +1233,16 @@ "cffcdc19881ab61e": {"kind":"object","properties":[{"name":"cleared","type":"0b6d718c693fdf7f","optional":false},{"name":"clearedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"kind","type":"237c004c73b6b288","optional":false},{"name":"operation","type":"e7e154d47d5fbc53","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "d0fbb0ca1bbba059": {"kind":"literal","value":"delete"}, "d28765f87d6b6ab4": {"kind":"literal","value":"one-shot"}, + "d49164d37eab0f0f": {"kind":"object","properties":[{"name":"data","type":"058fc9a6b4fbd5d4","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44c8c77aa9aa100b","optional":false}],"indices":[]}, "d4b739491689cc6b": {"kind":"object","properties":[{"name":"block","type":"8064c03c87c357fd","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"eefbea819caeeea9","optional":false}],"indices":[]}, "d4c799e3d51cae10": {"kind":"literal","value":"working-directory/change"}, "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, - "d595ec73b32b016a": {"kind":"object","properties":[{"name":"data","type":"a40d12070f6f4a12","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b57ae1b37e52bee0","optional":false}],"indices":[]}, "d63b8b8ffad9c02f": {"kind":"object","properties":[{"name":"data","type":"8bb8c3751a6ce64b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"47e5d84f1a93450a","optional":false}],"indices":[]}, "d66e11dddf5dfa3c": {"kind":"object","properties":[{"name":"data","type":"cef171adf021a186","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fb5e0517050d2c6d","optional":false}],"indices":[]}, "d6e654b88b584fcd": {"kind":"literal","value":"fallback"}, "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, "d8a476d2c6fd30df": {"kind":"literal","value":"team/mode"}, "d9196086e41e4dd5": {"kind":"literal","value":"complete"}, - "d95c624eddecdacf": {"kind":"object","properties":[{"name":"data","type":"601327bedca5f98a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, "da60ed58a8fa23d7": {"kind":"object","properties":[{"name":"cwd","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1239,12 +1252,11 @@ "db874d463b0fdec7": {"kind":"object","properties":[{"name":"data","type":"3e4e7683b0192c9f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"e2d0367e5716bc18","optional":false}],"indices":[]}, "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, "dbdafceb9a900183": {"kind":"literal","value":"hook"}, - "dcb07e04496586d2": {"kind":"object","properties":[{"name":"data","type":"613660455704fb82","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, + "dca3ba43188095fa": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","848639f8787f035b","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f1062573ea91841","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "dd2780e7e0768433": {"kind":"literal","value":"fresh"}, "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, - "ddce54a3873dc571": {"kind":"object","properties":[{"name":"data","type":"e1880c67e735e228","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "de25352d2dc41963": {"kind":"literal","value":"user/message"}, "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, @@ -1254,8 +1266,6 @@ "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, "dfce5db4aa42b7eb": {"kind":"union","types":["871bfd6f7bb91061","b57a2889408de36e","c8e6f48878cc2a2f"]}, "e0a787e6ec76c7c9": {"kind":"object","properties":[{"name":"data","type":"ef11a3b7fa2033ae","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"c219efbdfd53b836","optional":false}],"indices":[]}, - "e1880c67e735e228": {"kind":"object","properties":[{"name":"inserted","type":"690583582246babf","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, - "e272493e3ea0b33b": {"kind":"union","types":["01c161379a3c3394","09f3f8dc417f2782","4b259f7f8f0a44ad","613660455704fb82","fa87537f4a9c1e25"]}, "e2ad8f5bcd813f26": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "e2d0367e5716bc18": {"kind":"literal","value":"compaction/start"}, "e2f9a41e0989f54e": {"kind":"object","properties":[{"name":"data","type":"9947aa5938fe19a8","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ef4f00923b9edbae","optional":false}],"indices":[]}, @@ -1313,7 +1323,7 @@ "f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, "f6cb462f808309e3": {"kind":"literal","value":"replace"}, "f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, - "f731ffe80810b028": {"kind":"union","types":["0e10d7c096b47e61","5d542ac7bdf13310","a0f25b5aeeebf2bf","b6896938660a1dfe","cbbe8377ee744964","e2ad8f5bcd813f26"]}, + "f7752f518bb2f916": {"kind":"object","properties":[{"name":"inserted","type":"8a54d228b1b7d845","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, "f789af5ab844827e": {"kind":"literal","value":"change"}, "f88f1b7b6bb733f6": {"kind":"literal","value":1}, "f90eb4ab2d3897bc": {"kind":"object","properties":[{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 9bd04642b1..6501690aa5 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: e95b837b8f47eac8 - zh: bd69de97c80fe2ae + en: 1a570deda3572868 + zh: 4d256f68b7e39701 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 7d66caa4dd..bea5c3f895 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -33,7 +33,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [mcp.md](mcp.md) | external MCP connections, scoped tools and resources, server instructions, protocol results, and configuration ownership | | [user-questions.md](user-questions.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts | -| [office-to-pdf.md](office-to-pdf.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | +| [office-kit.md](office-kit.md) | authorized Office rendering, conversion, and recalculation, native/WASM engines, and bounded shared reuse | | [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [shell.md](shell.md) | the shell executor seam: `ShellExecRequest`/`Spec`, `ShellRunResult`, background `ShellProcess` handles | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 7358fe0203..4c714de6a0 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -33,7 +33,7 @@ | [mcp.md](mcp.zh.md) | 外部 MCP 连接、作用域工具与资源、服务器指令、协议结果与配置归属 | | [user-questions.md](user-questions.zh.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.zh.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 | -| [office-to-pdf.md](office-to-pdf.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | +| [office-kit.md](office-kit.zh.md) | 已授权的 Office 渲染、转换与重算、原生/WASM 引擎与有界共享复用 | | [attachment.md](attachment.zh.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [shell.md](shell.zh.md) | shell 执行器 seam:`ShellExecRequest`/`Spec`、`ShellRunResult`、后台 `ShellProcess` 句柄 | | [subprocess.md](subprocess.zh.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 435e1b6bbb..0426fb7a10 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -15,8 +15,8 @@ en: c70f0fd199fc7db5 zh: c6b77cb5a5cfd1c2 /agent-teams/web-projection: - en: 059d4e5d6a9ddc71 - zh: cb3fd92096144e19 + en: af4ffca1efab0f56 + zh: 1f53c9c258cc5a75 /agent-teams/replay: en: 448d32856f87b067 zh: 7fafd96314c7e2c5 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 49b27ea176..b89ce4204d 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -64,6 +64,8 @@ interface TeamTaskSnapshot { readonly description: string readonly status: TeamTaskStatus readonly ownerId?: SessionId + /** Immutable agent path captured at assignment; absent on unnamed historical owners. */ + readonly ownerName?: string readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] } @@ -77,7 +79,7 @@ interface TeamTaskSnapshot { `agentTeam.enabled` exposes the root Session’s saved selection. New Sessions default to false; `team/mode` records explicit selections. Historical tasks imply enabled until an explicit selection. Only the root can call `setEnabled`; descendants share that mode. The Web menu calls the same method through `remote.agentTeams.setEnabled`, with the root Session id resolved to its live Agent by the Gateway. Mode writes take effect before awaiting persistence. Tools resolve availability at step preparation; prompt providers read the selection when evaluated. Task mutations check the current mode when their queued operation starts; admitted mutations may finish. Mode changes do not end inbox waits. A failed flush leaves the appended selection active; retrying the same selection flushes it without a duplicate event. -The root Session publishes non-deleted task views through `SessionProjectionMap.agentTeam`. `failure` reports a rejected persisted task or mode record beside the last valid state. Historical roster and mailbox records are not projected. The task panel identifies every owner by its Session id. +The root Session publishes non-deleted task views through `SessionProjectionMap.agentTeam`. `failure` reports a rejected persisted task or mode record beside the last valid state. Historical roster and mailbox records are not projected. Views identify every owner by its Session id; the task panel and task tools display the owner's agent name, or the Session id when the owner has no name. ```ts type-equiv /** Runtime-enriched task view returned to tools and hosts. */ @@ -90,6 +92,8 @@ interface TeamTaskView { readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] readonly ownerId?: SessionId + /** Immutable agent path captured at assignment; absent on unnamed historical owners. */ + readonly ownerName?: string readonly ready: boolean readonly writeScopeWarnings: string[] } diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index fb52d49be4..2746c47697 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -64,6 +64,8 @@ interface TeamTaskSnapshot { readonly description: string readonly status: TeamTaskStatus readonly ownerId?: SessionId + /** Immutable agent path captured at assignment; absent on unnamed historical owners. */ + readonly ownerName?: string readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] } @@ -77,7 +79,7 @@ interface TeamTaskSnapshot { `agentTeam.enabled` 提供根 Session 保存的选择。新 Session 默认为 false;`team/mode` 记录显式选择。历史任务在显式选择前视为开启。仅根可调用 `setEnabled`;后代共享该模式。Web 菜单通过 `remote.agentTeams.setEnabled` 调用同一方法,Gateway 将根 Session id 解析为其存活 Agent。模式写入先立即生效,再等待刷盘。工具在步骤准备时确定可用性;提示词提供方在求值时读取选择。任务修改在排队操作开始时检查当前模式;已获准的修改可继续完成。模式变更不会结束收件箱等待。刷盘失败时已追加的选择仍然生效;重试相同选择会再次刷盘,不重复追加事件。 -根 Session 通过 `SessionProjectionMap.agentTeam` 发布未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久化任务或模式记录。历史名册与邮箱记录不参与投影。任务面板以 Session id 标识每个负责人。 +根 Session 通过 `SessionProjectionMap.agentTeam` 发布未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久化任务或模式记录。历史名册与邮箱记录不参与投影。视图以 Session id 标识每个负责人;任务面板和任务工具显示负责人的 agent 名称,负责人没有名称时显示其 Session id。 ```ts type-equiv /** Runtime-enriched task view returned to tools and hosts. */ @@ -90,6 +92,8 @@ interface TeamTaskView { readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] readonly ownerId?: SessionId + /** Immutable agent path captured at assignment; absent on unnamed historical owners. */ + readonly ownerName?: string readonly ready: boolean readonly writeScopeWarnings: string[] } diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index a1b1b47591..e4107281a9 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: 2cf6a1f62dcc1de6 - zh: b6d519b60f6f5c79 + en: a99f861a2d7089da + zh: 0decb374afa4014f /profile-management/market-records: - en: 3dfc62d837d61b9b - zh: ec223be315f3dc62 + en: a36e220bc3995386 + zh: 9b88d8606519368b /profile-management/profile-reconciliation: en: 42a093314af774d4 zh: 7c1784a3af02b31c diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 48b9b179e0..6780e92c43 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -10,9 +10,9 @@ 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, update eligibility (`updatable`), optional resolution or unsupported-platform 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 method (`update`: `registry` or `git`, absent when the bundle cannot be updated), optional resolution or unsupported-platform 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. +`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. `expected` names the package and manifest version the spec must install; a run that installs anything else is undone with its profile files and modules and fails with `unexpected-package`, one whose package declares no bundle fails with `not-bundle`, and one whose bundle patches cannot load fails as an operation error. `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. @@ -26,7 +26,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `MarketIndexSummary` carries one configured index URL with the `title` and `description` of its last successful read and, when the last read failed, its `error`. -`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. +`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`, `compatibility`, the evaluation of its `engines.dsh` including a version exemption, and `update`, whether the version can replace an installed Git copy through `updateBundle`: it is newer and pins a commit of the repository directory that copy came from. `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. @@ -267,15 +267,21 @@ Manage profile files and apply their declared reload lifecycle. @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. + * Replace an installed bundle with a newer version. A registry copy updates to `name@version`, checked on the registry + * first: it must exist, declare a bundle, and accept the running DSH version, or nothing changes. A Git copy updates + * to the Git spec `options.spec` names, which must pin a full commit of the repository directory the copy came from; + * with HMR, a network failure or timeout reaching its repository stops the update before anything changes. pnpm reads + * a Git package only while installing it, so its bundle and compatibility are checked then. Either target + * installation is undone, profile files and modules included, before its bundle loads unless it installs `name` at + * `version`. 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: a registry copy + * from the registry it came from, a Git copy from its saved spec. 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. + * @param version Exact version newer than the installed one. + * @param options The registry asked first, as for `listVersions`; the Git spec of `version`, required for and accepted + * only by a bundle `listBundles` lists with `update: 'git'`; 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 diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index d80c05fb53..915b6e8112 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -10,9 +10,9 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新资格(`updatable`)、可选的解析错误或平台不支持错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新方式(`update`:`registry` 或 `git`,组合包无法更新时不存在)、可选的解析错误或平台不支持错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 -`InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 +`InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。`expected` 指定该 spec 必须安装的包与清单版本;安装了其他任何内容的运行会连同其 profile 文件与模块一起被撤销,并以 `unexpected-package` 失败,所安装的包未声明组合包的运行以 `not-bundle` 失败,组合包 patch 无法加载的运行则以操作错误失败。 `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。 @@ -26,7 +26,7 @@ `MarketIndexSummary` 携带一个已配置的索引 URL,以及最近一次成功读取得到的 `title` 和 `description`;最近一次读取失败时,还携带其 `error`。 -`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` 评估结果。 +`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` 评估结果,以及 `update`,即该版本能否通过 `updateBundle` 替换已安装的 Git 副本:它比该副本新,且固定到该副本来源的仓库目录中的一个提交。 `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)。 @@ -267,15 +267,21 @@ Manage profile files and apply their declared reload lifecycle. @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. + * Replace an installed bundle with a newer version. A registry copy updates to `name@version`, checked on the registry + * first: it must exist, declare a bundle, and accept the running DSH version, or nothing changes. A Git copy updates + * to the Git spec `options.spec` names, which must pin a full commit of the repository directory the copy came from; + * with HMR, a network failure or timeout reaching its repository stops the update before anything changes. pnpm reads + * a Git package only while installing it, so its bundle and compatibility are checked then. Either target + * installation is undone, profile files and modules included, before its bundle loads unless it installs `name` at + * `version`. 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: a registry copy + * from the registry it came from, a Git copy from its saved spec. 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. + * @param version Exact version newer than the installed one. + * @param options The registry asked first, as for `listVersions`; the Git spec of `version`, required for and accepted + * only by a bundle `listBundles` lists with `update: 'git'`; 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 diff --git a/docs/subsystems/liveshare.i18n.yaml b/docs/subsystems/liveshare.i18n.yaml index e6afd9ab2f..b2ee50c232 100644 --- a/docs/subsystems/liveshare.i18n.yaml +++ b/docs/subsystems/liveshare.i18n.yaml @@ -9,11 +9,11 @@ en: f6144f8e81359a9c zh: 1924869ac7475b59 /liveshare/share-modes: - en: 08ce9269b4556bdd - zh: 7b0e6e74e2f60865 + en: 17b6227cb7294e69 + zh: cea4a9064b3d5655 /liveshare/forks-and-their-ceiling: - en: 357a2147f48f5bac - zh: a8a297603f984ede + en: 7174fa32aa611279 + zh: e85e8142a0e9e650 /liveshare/retries-recovery-and-revocation: en: 6b1346e73ab6eab9 zh: 888cfc08c6bc8522 diff --git a/docs/subsystems/liveshare.md b/docs/subsystems/liveshare.md index abf140dd59..8b86d4f50e 100644 --- a/docs/subsystems/liveshare.md +++ b/docs/subsystems/liveshare.md @@ -16,11 +16,11 @@ The plugin manager lists the card under **Experimental plugins** only while at l ## Share modes -`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. Both tiers also admit the PTC transport `run_code`: the program runs under the owner's standing file policy rather than the share tier, so it can read and write outside the grant, while its nested tool calls still pass the same guard and path checks. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven. +`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so its file tools never write; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. Both tiers also admit the PTC transport `run_code`: the program runs under the owner's standing file policy rather than the share tier, so it can read and write outside the grant, while its nested tool calls still pass the same guard and path checks. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven. ## Forks and their ceiling -A fork creates a new Session the viewer owns and leaves the source untouched. Fork authorization checks the share, its fork switch, expiry, viewer admission, source identity, and the requested boundary. `READ_ONLY_FORK_CEILING` permits the view surface — `read`, `read_image`, `glob`, `grep`, `present`, `todo_write`, `skill`, `web_search`, `web_fetch` — and `WRITE_FORK_CEILING` adds the Host’s bounded `edit` and `write`. Each path-bearing tool gets a confined replacement that resolves every declared path against the granted root and delegates with its canonical form; `bash`, `pwsh`, `cua`, `plugin_manager`, the `schedule_*` tools, and every name the surface does not list are refused. +A fork creates a new Session the viewer owns and leaves the source untouched. Fork authorization checks the share, its fork switch, expiry, viewer admission, source identity, and the requested boundary. `READ_ONLY_FORK_CEILING` permits the view surface — `read`, `read_image`, `glob`, `grep`, `present`, `todo_write`, `skill`, `web_search`, `web_fetch` — admits the PTC transport `run_code`, and `WRITE_FORK_CEILING` adds the Host’s bounded `edit` and `write`. Each path-bearing tool gets a confined replacement that resolves every declared path against the granted root and delegates with its canonical form; `bash`, `pwsh`, `cua`, `plugin_manager`, the `schedule_*` tools, and every other name is refused. Each child is recorded with the share that authorized it, the trusted viewer the Liveshare server asserted, and the boundary it started from, so a viewer's list shows only its own children and a removed share stops them. A child is driven only after its ceiling is installed on its own scope: `ForkRegistry` refuses every prompt for a child whose guard is not installed (`fork-ceiling-uninstalled`), and installation is verified per child rather than per host. A child whose scope refuses the confined replacement or the limit is refused instead of being published with the deployment's own tools. diff --git a/docs/subsystems/liveshare.zh.md b/docs/subsystems/liveshare.zh.md index cd30d9ee46..4420df05ca 100644 --- a/docs/subsystems/liveshare.zh.md +++ b/docs/subsystems/liveshare.zh.md @@ -16,11 +16,11 @@ ## 分享模式 -`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以读写授权范围之外的文件;它发起的嵌套工具调用仍走同样的守卫与路径检查。 +`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,其文件工具绝不写入;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以读写授权范围之外的文件;它发起的嵌套工具调用仍走同样的守卫与路径检查。 ## fork 与其上限 -fork 创建查看者拥有的新会话,源会话保持不变。Fork 授权检查分享、其 fork 开关、有效期、查看者准入、源身份与请求的边界。`READ_ONLY_FORK_CEILING` 允许读取与检索面——`read`、`read_image`、`glob`、`grep`、`present`、`todo_write`、`skill`、`web_search`、`web_fetch`——,`WRITE_FORK_CEILING` 增加 Host 的有界 `edit` 与 `write`。每个带路径的工具都有受限替代定义:解析它声明的所有路径并按授予根目录检查,再以规范路径委托;`bash`、`pwsh`、`cua`、`plugin_manager`、`schedule_*` 及工具面未列出的名字一律拒绝。 +fork 创建查看者拥有的新会话,源会话保持不变。Fork 授权检查分享、其 fork 开关、有效期、查看者准入、源身份与请求的边界。`READ_ONLY_FORK_CEILING` 允许读取与检索面——`read`、`read_image`、`glob`、`grep`、`present`、`todo_write`、`skill`、`web_search`、`web_fetch`——并放行 PTC 传输工具 `run_code`,`WRITE_FORK_CEILING` 增加 Host 的有界 `edit` 与 `write`。每个带路径的工具都有受限替代定义:解析它声明的所有路径并按授予根目录检查,再以规范路径委托;`bash`、`pwsh`、`cua`、`plugin_manager`、`schedule_*` 及其他任何未列出的名字一律拒绝。 每个分叉会话都会记录授权它的分享、Liveshare 服务端断言的可信 viewer,以及它起始的边界,因此 viewer 的列表只显示它自己的分叉会话,被移除的分享会终止它们。只有在分叉会话自身作用域上安装好上限之后它才会被驱动:`ForkRegistry` 会拒绝每个上限未安装的分叉会话的 prompt(`fork-ceiling-uninstalled`),且安装是按分叉会话逐个验证,而非按宿主机验证。若分叉会话作用域拒绝注册受限替换或上限,就拒绝该分叉会话,而不是让它带着部署自身的工具发布。 diff --git a/docs/subsystems/office-kit.i18n.yaml b/docs/subsystems/office-kit.i18n.yaml new file mode 100644 index 0000000000..4f7dd739b6 --- /dev/null +++ b/docs/subsystems/office-kit.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for office-kit.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/subsystems/office-kit.md +/office-kit: + en: ba1e27aece3a68c3 + zh: 50ca71dd6c6a5438 +/office-kit/ownership: + en: 26905a1cd6947042 + zh: 16c00f451ddd15b0 +/office-kit/requests-and-results: + en: 4ae99f63ed85ce7f + zh: 0db77cfc734788b1 +/office-kit/preview-reads: + en: 3a373dfc9dd945c3 + zh: ff5f9a16d0e7b96f +/office-kit/engine-selection-and-limits: + en: a26e451e9e481c52 + zh: da1f4a160a6c2094 diff --git a/docs/subsystems/office-kit.md b/docs/subsystems/office-kit.md new file mode 100644 index 0000000000..63e694233a --- /dev/null +++ b/docs/subsystems/office-kit.md @@ -0,0 +1,122 @@ +# Office Kit + +English | [中文](office-kit.zh.md) + +The [document package family](../../packages/document/README.md) renders, converts, and recalculates Office files on the Node Host. Consumers authorize source reads and own presentation; the shared provider owns the engine, bounded admission, transient output reuse, and the private output store. The [`office_render`, `office_convert`, and `office_recalculate` tools](../../packages/document/tool-office/README.md) are the model-facing consumers; the provider itself creates no Session event. + +## Ownership + +| Owner | Responsibility | +|---|---| +| [office-kit](../../packages/document/office-kit/README.md) | `ctx.officeKit`: shared LibreOffice rendering, conversion, and recalculation, bounded admission, output caching, and the per-Session output store; resolution of the provider's own Kit entry, engine manifests from that installation, and native paths mapped to ASAR unpacked directories | +| [tool-office](../../packages/document/tool-office/README.md) | The on-demand `office_render`, `office_convert`, and `office_recalculate` tools: rendered images in tool results and converted or recalculated files returned for a shell copy | +| [Web bundle](../../packages/bundle/web-app/README.md) and [SDK bundle](../../packages/bundle/sdk-app/README.md) | One configurable provider and `tool-office` shared by Host consumers | +| [Office preview Client](../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) | Office extension selection, PDF reuse, and missing-font notices | + +## Requests and results + +[`OfficeKitRequest`](../../packages/document/office-kit/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `OfficeExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `OfficeKit.convert(request, signal?)` returns one complete PDF result. `renderImages()` returns bounded PNG images of selected pages, slides, or worksheet areas; `convertTo()` and `recalculate()` save the output under the provider's `outputDirectory` for the request's owner and return its Host path. Their request types accept OpenDocument inputs, and image rendering accepts PDF. All methods share admission and caching. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `OfficeKitError`. + +`OfficeKitPriority` is `foreground` for requested preview/QA and `background` for speculation. `OfficeSourceKey` brands the caller-owned authorized source locator. `OfficeKitGeneration` brands a provider lifetime, and `OfficeKitKey` brands its content identity; neither opaque value is parsed by consumers. + +| Result field | Meaning | +|---|---| +| `pdf` | Caller-owned `Uint8Array` containing the complete PDF | +| `missingFonts` | Requested document font families unavailable to this conversion | +| `cacheKey` | Opaque converter generation plus extension/source-content identity | +| `generation` | Provider lifetime; replacement invalidates cached PDF reuse | + +The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads. + +## Preview reads + +`RenderedDocumentBytes` carries workspace file metadata, native PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF. + +The `officeKit.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The binary Remote projects the PDF into a multipart attachment and restores an `ArrayBuffer`-backed `Uint8Array` on the Client. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events. + +The `api/remotes` assembly mounts the conversion service's generated Remote descriptor. The shared Document Preview package registers Office formats with complete-byte loading and its existing PDF.js Worker. Each preview read rechecks renderer generation, source authorization, and version before sharing an in-flight conversion or cached PDF. Connection resets and plugin disposal cancel requests and clear cached bytes. Missing services show localized configuration guidance. + +## Engine selection and limits + +The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine reference](../../packages/document/office-kit/README.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. When the provider's Kit is absent or fails to import, the provider still registers and rejects conversions as `unavailable`. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC. + +The [Host provider configuration](../../packages/document/office-kit/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent output caching, workspace writes, and model-facing presentation are outside this provider. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.officeKit` — `OfficeKit` + +A provider lifetime owns all converters, queued calls, and temporary files. + +```ts cordis-catalog +/** + * Convert Office bytes to PDF without modifying the source or writing Session events. + * @param request - authorized metadata and deferred bounded source read. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. + * @throws {OfficeKitError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +async convert(request: OfficeKitRequest, signal?: AbortSignal): Promise + +/** + * Render selected pages, slides, or worksheet areas of one document as PNG images. Requests share the admission + * queue, concurrency, source-byte reservation, and cache with previews, and run at foreground priority. + * @param request - authorized metadata, deferred bounded source read, selection, and complete-result limits. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned images in document order whose count, per-image size, and total bytes satisfy the limits. + * @throws {OfficeKitError} `invalid-request` for a selection the engine rejects, `output-too-large` when the + * selection exceeds a limit, and the other conversion codes; cancellation rejects with its reason. + */ +async renderImages(request: OfficeImagesRequest, signal?: AbortSignal): Promise + +/** + * Convert one document to another format at foreground priority, sharing admission, concurrency, and cache with + * previews, and save the result in the provider's output store. + * @param request - authorized metadata, deferred bounded source read, target format, and output owner and name. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns the saved file's absolute Host path and size; the file is deleted after `outputRetentionMs`, when older + * outputs need `maxOutputStoreBytes`, when the Session whose id is `output.owner` is disposed, or when the provider is disposed. + * @throws {OfficeKitError} `invalid-request` for a format or sheet the engine rejects and the other conversion + * codes; cancellation rejects with its reason. + */ +async convertTo(request: OfficeConversionRequest, signal?: AbortSignal): Promise + +/** + * Recalculate every formula of one workbook at foreground priority and save formulas with refreshed cached results, sharing admission, + * concurrency, and cache with previews, and save the workbook in the provider's output store. This does not validate + * formulas or business data. + * @param request - authorized workbook metadata, deferred bounded source read, output format, and output owner and name. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns the saved workbook's absolute Host path and size, retained like {@link convertTo} outputs. + * @throws {OfficeKitError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +async recalculate(request: OfficeRecalculationRequest, signal?: AbortSignal): Promise + +/** + * Read and convert one Office file using the Session's ordinary filesystem authorization. + * @param workspaceFileScope - Session header lookup shared with workspaceFiles. + * @param path - absolute or workspace-relative Office path. + * @param priority - foreground preview or speculative background work. + * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. + * @returns complete PDF bytes with original source identity and missing font families. + */ +@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeKitPriority, signal: AbortSignal, ): Promise + +/** + * Read the current rendering generation before reusing a Client PDF. + * @param signal - Remote caller cancellation. + * @returns provider lifetime, replaced with rendering, font, or engine configuration. + */ +@Remote('generation') getGeneration(signal: AbortSignal): OfficeKitGeneration +``` + +Source: [`packages/document/office-kit/src/index.ts`](../../packages/document/office-kit/src/index.ts) + diff --git a/docs/subsystems/office-kit.zh.md b/docs/subsystems/office-kit.zh.md new file mode 100644 index 0000000000..385acbe192 --- /dev/null +++ b/docs/subsystems/office-kit.zh.md @@ -0,0 +1,122 @@ +# Office Kit + +[English](office-kit.md) | 中文 + +[document 包族](../../packages/document/README.zh.md) 在 Node 宿主上渲染、转换 Office 文件并重算工作簿。消费者负责源文件读取授权与展示;共享提供方负责引擎、有界准入、临时输出复用和私有输出存储。[`office_render`、`office_convert` 和 `office_recalculate` 工具](../../packages/document/tool-office/README.zh.md)是面向模型的消费者;提供方本身不创建 Session 事件。 + +## 所有权 + +| 所有者 | 职责 | +|---|---| +| [office-kit](../../packages/document/office-kit/README.zh.md) | `ctx.officeKit`:共享 LibreOffice 渲染、转换与重算,有界准入、输出缓存和按 Session 划分的输出存储;解析提供方自身的 Kit 入口、从该安装定位的引擎清单,以及映射到 ASAR 解包目录的原生路径 | +| [tool-office](../../packages/document/tool-office/README.zh.md) | 按需激活的 `office_render`、`office_convert` 和 `office_recalculate` 工具:工具结果中的渲染图片,以及供 shell 复制的转换或重算文件 | +| [Web bundle](../../packages/bundle/web-app/README.zh.md)与 [SDK bundle](../../packages/bundle/sdk-app/README.zh.md) | 由宿主消费者共享的单个可配置提供方及 `tool-office` | +| [Office 预览 Client](../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview) | Office 扩展名选择、PDF 复用和缺失字体提示 | + +## 请求和结果 + +[`OfficeKitRequest`](../../packages/document/office-kit/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `OfficeExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`OfficeKit.convert(request, signal?)` 返回一个完整 PDF 结果。`renderImages()` 返回选定页、幻灯片或工作表区域的有界 PNG 图片;`convertTo()` 与 `recalculate()` 把输出按请求的属主保存在提供方的 `outputDirectory` 下并返回其 Host 路径。它们的请求类型接受 OpenDocument 输入,图片渲染还接受 PDF。所有方法共用准入和缓存。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `OfficeKitError` 拒绝。 + +`OfficeKitPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`OfficeSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`OfficeKitGeneration` 表示提供方生命周期,`OfficeKitKey` 表示其内容身份;消费者不解析这两种不透明值。 + +| 结果字段 | 含义 | +|---|---| +| `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF | +| `missingFonts` | 本次转换无法使用的文档请求字体名称 | +| `cacheKey` | 不透明的转换 generation 加扩展名与源内容身份 | +| `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 | + +提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。 + +## 预览读取 + +`RenderedDocumentBytes` 携带工作区文件元数据、原生 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 + +`officeKit.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。二进制 Remote 将 PDF 投影为 multipart 附件,并在 Client 恢复为由 `ArrayBuffer` 支撑的 `Uint8Array`。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。 + +`api/remotes` 挂载转换服务生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。 + +## 引擎选择和限制 + +外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎参考](../../packages/document/office-kit/README.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。提供方的 Kit 缺失或导入失败时,提供方仍会注册,并以 `unavailable` 拒绝转换。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 + +[Host 提供方配置](../../packages/document/office-kit/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久输出缓存、写入工作区和面向模型的展示不属于此提供方。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.officeKit` — `OfficeKit` + +A provider lifetime owns all converters, queued calls, and temporary files. + +```ts cordis-catalog +/** + * Convert Office bytes to PDF without modifying the source or writing Session events. + * @param request - authorized metadata and deferred bounded source read. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. + * @throws {OfficeKitError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +async convert(request: OfficeKitRequest, signal?: AbortSignal): Promise + +/** + * Render selected pages, slides, or worksheet areas of one document as PNG images. Requests share the admission + * queue, concurrency, source-byte reservation, and cache with previews, and run at foreground priority. + * @param request - authorized metadata, deferred bounded source read, selection, and complete-result limits. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned images in document order whose count, per-image size, and total bytes satisfy the limits. + * @throws {OfficeKitError} `invalid-request` for a selection the engine rejects, `output-too-large` when the + * selection exceeds a limit, and the other conversion codes; cancellation rejects with its reason. + */ +async renderImages(request: OfficeImagesRequest, signal?: AbortSignal): Promise + +/** + * Convert one document to another format at foreground priority, sharing admission, concurrency, and cache with + * previews, and save the result in the provider's output store. + * @param request - authorized metadata, deferred bounded source read, target format, and output owner and name. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns the saved file's absolute Host path and size; the file is deleted after `outputRetentionMs`, when older + * outputs need `maxOutputStoreBytes`, when the Session whose id is `output.owner` is disposed, or when the provider is disposed. + * @throws {OfficeKitError} `invalid-request` for a format or sheet the engine rejects and the other conversion + * codes; cancellation rejects with its reason. + */ +async convertTo(request: OfficeConversionRequest, signal?: AbortSignal): Promise + +/** + * Recalculate every formula of one workbook at foreground priority and save formulas with refreshed cached results, sharing admission, + * concurrency, and cache with previews, and save the workbook in the provider's output store. This does not validate + * formulas or business data. + * @param request - authorized workbook metadata, deferred bounded source read, output format, and output owner and name. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns the saved workbook's absolute Host path and size, retained like {@link convertTo} outputs. + * @throws {OfficeKitError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +async recalculate(request: OfficeRecalculationRequest, signal?: AbortSignal): Promise + +/** + * Read and convert one Office file using the Session's ordinary filesystem authorization. + * @param workspaceFileScope - Session header lookup shared with workspaceFiles. + * @param path - absolute or workspace-relative Office path. + * @param priority - foreground preview or speculative background work. + * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. + * @returns complete PDF bytes with original source identity and missing font families. + */ +@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeKitPriority, signal: AbortSignal, ): Promise + +/** + * Read the current rendering generation before reusing a Client PDF. + * @param signal - Remote caller cancellation. + * @returns provider lifetime, replaced with rendering, font, or engine configuration. + */ +@Remote('generation') getGeneration(signal: AbortSignal): OfficeKitGeneration +``` + +Source: [`packages/document/office-kit/src/index.ts`](../../packages/document/office-kit/src/index.ts) + diff --git a/docs/subsystems/office-to-pdf.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml deleted file mode 100644 index 309b486103..0000000000 --- a/docs/subsystems/office-to-pdf.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for office-to-pdf.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/subsystems/office-to-pdf.md -/office-to-pdf: - en: 9a24c6158f9fb214 - zh: 773155d0e59fffe2 -/office-to-pdf/ownership: - en: b21c1a3345590b3a - zh: d402b06930599e07 -/office-to-pdf/requests-and-results: - en: 8a1fa9eb6e82ef8f - zh: e263ca49c5c957d6 -/office-to-pdf/preview-reads: - en: 715105e6fff89e7c - zh: a7fe63c7dd0be17f -/office-to-pdf/engine-selection-and-limits: - en: 3908dab1b7d78890 - zh: 8f2aed6e27c5b5dd diff --git a/docs/subsystems/office-to-pdf.md b/docs/subsystems/office-to-pdf.md deleted file mode 100644 index 92357607bb..0000000000 --- a/docs/subsystems/office-to-pdf.md +++ /dev/null @@ -1,87 +0,0 @@ -# Office to PDF - -English | [中文](office-to-pdf.zh.md) - -The [document package family](../../packages/document/README.md) converts Office files to PDFs on the Node Host. Consumers authorize source reads and own presentation; the shared provider owns conversion, bounded admission, and transient PDF reuse. This subsystem creates no model-facing tool or Session event. - -## Ownership - -| Owner | Responsibility | -|---|---| -| [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching | -| [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | -| [Office preview Client](../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) | Office extension selection, PDF reuse, and missing-font notices | - -## Requests and results - -[`OfficeToPdfRequest`](../../packages/document/office-to-pdf/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `OfficeExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `OfficeToPdf.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `OfficeToPdfError`. - -`OfficeToPdfPriority` is `foreground` for requested preview/QA and `background` for speculation. `OfficeSourceKey` brands the caller-owned authorized source locator. `OfficeToPdfGeneration` brands a provider lifetime, and `OfficeToPdfKey` brands its content identity; neither opaque value is parsed by consumers. - -| Result field | Meaning | -|---|---| -| `pdf` | Caller-owned `Uint8Array` containing the complete PDF | -| `missingFonts` | Requested document font families unavailable to this conversion | -| `cacheKey` | Opaque converter generation plus extension/source-content identity | -| `generation` | Provider lifetime; replacement invalidates cached PDF reuse | - -The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads. - -## Preview reads - -`RenderedDocumentBytes` carries workspace file metadata, native PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF. - -The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The binary Remote projects the PDF into a multipart attachment and restores an `ArrayBuffer`-backed `Uint8Array` on the Client. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events. - -The `api/remotes` assembly mounts the conversion service's generated Remote descriptor. The shared Document Preview package registers Office formats with complete-byte loading and its existing PDF.js Worker. Each preview read rechecks renderer generation, source authorization, and version before sharing an in-flight conversion or cached PDF. Connection resets and plugin disposal cancel requests and clear cached bytes. Missing services show localized configuration guidance. - -## Engine selection and limits - -The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine reference](../../packages/document/office-to-pdf/README.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC. - -The [Host provider configuration](../../packages/document/office-to-pdf/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider. - - - - - -## Cordis API - -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - - -### `ctx.officeToPdf` — `OfficeToPdf` - -A provider lifetime owns all converters, queued calls, and temporary files. - -```ts cordis-catalog -/** - * Convert Office bytes without modifying the source or writing Session events. - * @param request - authorized metadata and deferred bounded source read. - * @param signal - caller cancellation; provider disposal also stops active work. - * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. - */ -convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise - -/** - * Read and convert one Office file using the Session's ordinary filesystem authorization. - * @param workspaceFileScope - Session header lookup shared with workspaceFiles. - * @param path - absolute or workspace-relative Office path. - * @param priority - foreground preview or speculative background work. - * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. - * @returns complete PDF bytes with original source identity and missing font families. - */ -@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise - -/** - * Read the current rendering generation before reusing a Client PDF. - * @param signal - Remote caller cancellation. - * @returns provider lifetime, replaced with rendering, font, or engine configuration. - */ -@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration -``` - -Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts) - diff --git a/docs/subsystems/office-to-pdf.zh.md b/docs/subsystems/office-to-pdf.zh.md deleted file mode 100644 index 88b0e8116b..0000000000 --- a/docs/subsystems/office-to-pdf.zh.md +++ /dev/null @@ -1,87 +0,0 @@ -# Office 转 PDF - -[English](office-to-pdf.md) | 中文 - -[document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。 - -## 所有权 - -| 所有者 | 职责 | -|---|---| -| [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 | -| [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | -| [Office 预览 Client](../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview) | Office 扩展名选择、PDF 复用和缺失字体提示 | - -## 请求和结果 - -[`OfficeToPdfRequest`](../../packages/document/office-to-pdf/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `OfficeExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`OfficeToPdf.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `OfficeToPdfError` 拒绝。 - -`OfficeToPdfPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`OfficeSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`OfficeToPdfGeneration` 表示提供方生命周期,`OfficeToPdfKey` 表示其内容身份;消费者不解析这两种不透明值。 - -| 结果字段 | 含义 | -|---|---| -| `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF | -| `missingFonts` | 本次转换无法使用的文档请求字体名称 | -| `cacheKey` | 不透明的转换 generation 加扩展名与源内容身份 | -| `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 | - -提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。 - -## 预览读取 - -`RenderedDocumentBytes` 携带工作区文件元数据、原生 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 - -`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。二进制 Remote 将 PDF 投影为 multipart 附件,并在 Client 恢复为由 `ArrayBuffer` 支撑的 `Uint8Array`。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。 - -`api/remotes` 挂载转换服务生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。 - -## 引擎选择和限制 - -外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎参考](../../packages/document/office-to-pdf/README.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 - -[Host 提供方配置](../../packages/document/office-to-pdf/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 - - - - - -## Cordis API - -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - - -### `ctx.officeToPdf` — `OfficeToPdf` - -A provider lifetime owns all converters, queued calls, and temporary files. - -```ts cordis-catalog -/** - * Convert Office bytes without modifying the source or writing Session events. - * @param request - authorized metadata and deferred bounded source read. - * @param signal - caller cancellation; provider disposal also stops active work. - * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. - */ -convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise - -/** - * Read and convert one Office file using the Session's ordinary filesystem authorization. - * @param workspaceFileScope - Session header lookup shared with workspaceFiles. - * @param path - absolute or workspace-relative Office path. - * @param priority - foreground preview or speculative background work. - * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions. - * @returns complete PDF bytes with original source identity and missing font families. - */ -@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise - -/** - * Read the current rendering generation before reusing a Client PDF. - * @param signal - Remote caller cancellation. - * @returns provider lifetime, replaced with rendering, font, or engine configuration. - */ -@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration -``` - -Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts) - diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 7f908ead84..3d2954b1c4 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -6,11 +6,11 @@ en: e0d4876306b192c3 zh: 63baaa0c79e87deb /process-sandbox/modes-and-enforcement: - en: 6777e1b1270ec3e1 - zh: 22533fb3304c1dd8 + en: 3e518b62c9bbcd6e + zh: d59d4a3830ce7cf6 /process-sandbox/per-call-policy: - en: fac6f5d32327dfb6 - zh: 1b8e4a1af48f33e8 + en: 53850780ce70e3d8 + zh: 60769c319d778e0e /process-sandbox/wrapped-argv-and-classification-dialects: en: 2ba8930762e92e40 zh: 220f64da1a9e5d57 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 1b8c843049..c530276e90 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -8,13 +8,13 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ## Modes and enforcement -`SandboxMode` governs filesystem effects only. `read-only` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root, explicit extra directories, and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. +`SandboxMode` governs filesystem effects only. `read-only` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root, extra directories where supported, and the backend's promised temp area; `danger-full-access` bypasses confinement. Windows subprocesses ignore extra directory grants; the harness file tools enforce them independently. Network and process visibility are outside this vocabulary. ```ts type-equiv /** * File-effect policy for confined processes. `read-only` permits only required * sinks such as `/dev/null`; `workspace-write` also permits the workspace, - * explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network + * explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network * and process visibility are outside this vocabulary. */ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration supplies the agentless fallback and explicit additional write grants. The resolver preserves absolute execution-world spelling. Enforcing providers canonicalize the root where the files exist, so a cwd containing `symlink/..` identifies the directory where the paired subprocess provider actually runs. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration supplies the agentless fallback and shared additional write grants. Live Session registrations add effect-scoped directories to subsequent resolutions. The resolver preserves absolute execution-world spelling. Enforcing providers canonicalize the root where the files exist, so a cwd containing `symlink/..` identifies the directory where the paired subprocess provider actually runs. ```ts type-equiv /** @@ -57,6 +57,7 @@ interface SandboxExecutionPolicy { * Extra physical absolute directories `workspace-write` may also write under. * Absence means none. The enforcing host rejects missing, non-directory, and * symlinked entries on each call. Remote paths are checked on the remote host. + * Windows process confinement ignores these; harness filesystem operations honor them. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -200,7 +201,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. ```ts cordis-catalog /** @@ -208,12 +209,27 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * mode outranks the session's last `sandbox/mode` event, which outranks the * deployment default. A session cwd is its workspace-write boundary; the * configured root is the fallback for agentless calls and sessions without a - * cwd. + * cwd. Configured extra roots and this Session's active registrations are + * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy +/** + * Permit writes under one directory in this Session's future workspace-write + * operations. The calling context owns the effect; disposal removes only this + * registration. Other grants to the directory remain effective. Existing + * operations and persistent processes retain their resolved permissions. + * Grants are runtime-only and must be registered again after resume or fork. + * On Windows, only harness file tools honor extra grants; process permissions stay unchanged. + * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. + * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. + * @returns the exact Cordis effect disposer for this registration. + * @throws when the directory is relative or contains a NUL byte. + */ +registerWritableRoot(session: Session, directory: string): () => void + /** * Read the session override without applying the deployment default. * @param session - session whose log supplies the override. diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 8141d365c0..c3cfd0720e 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -8,13 +8,13 @@ ## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录、显式额外目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录、受支持的额外目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。Windows 子进程忽略额外目录授权;Harness 文件工具独立执行这些授权。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv /** * File-effect policy for confined processes. `read-only` permits only required * sinks such as `/dev/null`; `workspace-write` also permits the workspace, - * explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network + * explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network * and process visibility are outside this vocabulary. */ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置提供无 agent 调用的回退值和显式额外写入授权。解析器保留执行环境中的绝对路径写法。执行限制的提供方在文件实际存在的位置规范化根目录,因此包含 `symlink/..` 的 cwd 会标识配套子进程提供方实际运行的目录。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置提供无 agent 调用的回退值和共享的额外写入授权。存活 Session 的注册为后续解析添加 effect 生命周期内的目录授权。解析器保留执行环境中的绝对路径写法。执行限制的提供方在文件实际存在的位置规范化根目录,因此包含 `symlink/..` 的 cwd 会标识配套子进程提供方实际运行的目录。 ```ts type-equiv /** @@ -57,6 +57,7 @@ interface SandboxExecutionPolicy { * Extra physical absolute directories `workspace-write` may also write under. * Absence means none. The enforcing host rejects missing, non-directory, and * symlinked entries on each call. Remote paths are checked on the remote host. + * Windows process confinement ignores these; harness filesystem operations honor them. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -200,7 +201,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. ```ts cordis-catalog /** @@ -208,12 +209,27 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * mode outranks the session's last `sandbox/mode` event, which outranks the * deployment default. A session cwd is its workspace-write boundary; the * configured root is the fallback for agentless calls and sessions without a - * cwd. + * cwd. Configured extra roots and this Session's active registrations are + * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy +/** + * Permit writes under one directory in this Session's future workspace-write + * operations. The calling context owns the effect; disposal removes only this + * registration. Other grants to the directory remain effective. Existing + * operations and persistent processes retain their resolved permissions. + * Grants are runtime-only and must be registered again after resume or fork. + * On Windows, only harness file tools honor extra grants; process permissions stay unchanged. + * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. + * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. + * @returns the exact Cordis effect disposer for this registration. + * @throws when the directory is relative or contains a NUL byte. + */ +registerWritableRoot(session: Session, directory: string): () => void + /** * Read the session override without applying the deployment default. * @param session - session whose log supplies the override. diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 512542d505..01dbdd99e2 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -96,10 +96,10 @@ interface ProjectionDefinition { summary?: boolean } : never /** - * Persisted-cache invalidation version: bump whenever the serialized state fields or the - * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` - * rows from an older unit are discarded instead of being forward-applied - * into garbage. Non-negative integer. + * Non-negative persisted-cache invalidation version. Bump when state fields or fold + * semantics change, except for optional additions whose absence preserves historical + * meaning and whose old-checkpoint suffix replay matches full replay. Test that + * equivalence before retaining a version; schema acceptance alone is insufficient. */ stateVersion: number } diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index d6b38f28d3..675d49b575 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -96,10 +96,10 @@ interface ProjectionDefinition { summary?: boolean } : never /** - * Persisted-cache invalidation version: bump whenever the serialized state fields or the - * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` - * rows from an older unit are discarded instead of being forward-applied - * into garbage. Non-negative integer. + * Non-negative persisted-cache invalidation version. Bump when state fields or fold + * semantics change, except for optional additions whose absence preserves historical + * meaning and whose old-checkpoint suffix replay matches full replay. Test that + * equivalence before retaining a version; schema acceptance alone is insufficient. */ stateVersion: number } diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 482969fe6c..37bd0f9433 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -21,8 +21,8 @@ en: 6b5faaed58ab5fb1 zh: 2118439d3403a735 /right-sidebar/document-renderers: - en: 2bf47a890fce175e - zh: 45f81c579b8939a8 + en: 5e7e08977a598765 + zh: 73181ef0cbf8e62b /right-sidebar/resource-model: en: 2916306e6cb9282c zh: cdec047989860202 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 00a0671bae..5cc25a74bc 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -123,7 +123,7 @@ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/docum Preview records its loaded version and the version observed when a read starts. Automatic refresh defaults to enabled; initial member metadata establishes a baseline, and later changes reload the tab. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). -A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, failed, reload }` instead of file bytes. The body invokes its injected face, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. A failed load calls `failed()` so a later file change can retry. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its face writes converted bytes, font metadata, and failures to its own store, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle. +A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, failed, reload }` instead of file bytes. The body invokes its injected face, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. A failed load calls `failed()` so a later file change can retry. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-kit.md); its face writes converted bytes, font metadata, and failures to its own store, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle. ## Resource model diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 72c6db445f..08e1902f1b 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -123,7 +123,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed Preview 记录已载入版本和读取开始时的观察版本。自动刷新默认开启;成员首次元数据只建立基线,后续变化才重新加载 tab。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 -自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文调用自己的注入 face,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);其 face 将转换字节、字体元数据和失败写入自己的 store,正文渲染嵌套 PDF 视图。`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。 +自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文调用自己的注入 face,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-kit.zh.md);其 face 将转换字节、字体元数据和失败写入自己的 store,正文渲染嵌套 PDF 视图。`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。 ## 资源模型 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index b2d7d88b37..8a6bd71c02 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -11,6 +11,9 @@ /subagents/local-children-and-activations: en: 6f87231d18d82916 zh: 9f064b23058f8766 +/subagents/agent-names: + en: 55b2912080a2b021 + zh: d234521ecce44275 /subagents/messages-and-interruption: en: 9befacb931520842 zh: c214b4fffa779b06 @@ -24,5 +27,5 @@ en: 226ba8094a8cf96c zh: 719678662905d1eb /subagents/durable-enumeration-listdescendants-and-catalog-entries: - en: 8000138f84fd15fa - zh: 32fa91d3aebd3d01 + en: 992acbcc3ac77e30 + zh: 83b9dd42ade734ab diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 0e3235cf91..488ef66ac4 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -33,6 +33,12 @@ interface SubagentActivationSpec extends Omit */ rootOf(caller: Agent): Agent +/** + * Report the model-facing names of a live tree member and its direct creator. + * The root is `lead`; a descendant is the slash-separated path of names from + * the root, or its Session id when it or a creator below the root has no name. + * @param agent - exact live root or resident tree member. + * @returns the member's name and, for a descendant, its creator's name. + * @throws when the caller or its runtime owner is unavailable. + */ +namesOf(agent: Agent): SubagentMemberNames + +/** + * Resolve a model-supplied agent reference within the caller's tree. `lead` + * selects the root and a slash-separated path is looked up through the + * catalogs along it. Any other text that is not a name in the tree is + * returned as a Session id, because a single token can be one; a + * slash-separated address never is, so an unmatched path fails instead of + * reaching a member lookup as an unknown id. Resolution grants no authority: + * messaging, interruption, and assignment authorize the result. + * @param caller - exact live Agent whose tree root anchors the lookup. + * @param name - `lead`, a full name from the root, or a Session id. + * @param signal - cancellation forwarded to each catalog read. + * @returns the referenced Session id. + * @throws {@link SubagentError} `AGENT_NAME_NOT_FOUND` when a path names no member; + * when the caller's root is unavailable; or when a catalog along the path cannot be read. + */ +async resolveName(caller: Agent, name: string, signal: AbortSignal): Promise + /** * Join progressing subagent tree work for its root. Idle members with parked input * stay resident without delaying host completion. Ordinary members return false. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 785644d1cf..8f1d6b74a2 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -33,6 +33,12 @@ interface SubagentActivationSpec extends Omit */ rootOf(caller: Agent): Agent +/** + * Report the model-facing names of a live tree member and its direct creator. + * The root is `lead`; a descendant is the slash-separated path of names from + * the root, or its Session id when it or a creator below the root has no name. + * @param agent - exact live root or resident tree member. + * @returns the member's name and, for a descendant, its creator's name. + * @throws when the caller or its runtime owner is unavailable. + */ +namesOf(agent: Agent): SubagentMemberNames + +/** + * Resolve a model-supplied agent reference within the caller's tree. `lead` + * selects the root and a slash-separated path is looked up through the + * catalogs along it. Any other text that is not a name in the tree is + * returned as a Session id, because a single token can be one; a + * slash-separated address never is, so an unmatched path fails instead of + * reaching a member lookup as an unknown id. Resolution grants no authority: + * messaging, interruption, and assignment authorize the result. + * @param caller - exact live Agent whose tree root anchors the lookup. + * @param name - `lead`, a full name from the root, or a Session id. + * @param signal - cancellation forwarded to each catalog read. + * @returns the referenced Session id. + * @throws {@link SubagentError} `AGENT_NAME_NOT_FOUND` when a path names no member; + * when the caller's root is unavailable; or when a catalog along the path cannot be read. + */ +async resolveName(caller: Agent, name: string, signal: AbortSignal): Promise + /** * Join progressing subagent tree work for its root. Idle members with parked input * stay resident without delaying host completion. Ordinary members return false. diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index f35d7ae166..c155ec4cb6 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -21,8 +21,8 @@ en: 14522295539ea2fb zh: 572c462f2c6b70ad /tools/execution-extensible-waterfalls-plus-monotonic-policy: - en: 356bf8128685d0fe - zh: a28541da29c3deed + en: 0baa3a0d6fb8d3be + zh: 3583d70d2507cac8 /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index e94206a685..02b213abdb 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -522,7 +522,7 @@ interface ToolErrorInfo { } ``` -The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/ptc-dispatch` stores the sub-call's rendered `content`, `isError`, optional structured `error`, and presentation `meta`. Replay reproduces presentation but cannot reconstruct canonical intermediate values. Optional `ToolErrorInfo.reason` retains raw user-facing detail without adding it to model-facing content. +The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/ptc-dispatch` stores the sub-call's rendered `content`, `isError`, optional structured `error`, and presentation `meta`. Replay reproduces presentation but cannot reconstruct canonical intermediate values. Optional `ToolErrorInfo.reason` retains raw user-facing detail without adding it to model-facing content. The registry sets `info` from a thrown `HarnessError` (its `name` and `code`), from a pre-execute `deny` that supplies it, and on its own cancellation results (`AbortError` with `ABORTED` or `ABORTED_BEFORE_DISPATCH`); a `tools/execute` wrapper may also return a result with `info`, such as the timeout policy's `TOOL_TIMEOUT`. Any other thrown value, guard denial, approval rejection, or post-execute block produces an `error` without `info`. On success the registry snapshots and validates the body value, freezes it, and invokes the pure renderer plus the optional presentation metadata projector for native and nested calls. It separately materializes the durable presentation fields immediately before `tools/result`; an invalid value, renderer/projector failure, or non-JSON presentation becomes a JSON-safe `isError`. The final live observer therefore sees the exact execution-local value beside fields safe for the later durable append. diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index e18dfbbe5d..32e333c298 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -522,7 +522,7 @@ interface ToolErrorInfo { } ``` -结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。规范的 `value` 仅存在于执行期间:循环只持久化 `content`、`error` 和 `meta`,`tool/ptc-dispatch` 则存储子调用渲染后的 `content`、`isError`、可选结构化 `error` 和展示 `meta`。回放可以重现展示,却无法重建规范的中间值。可选的 `ToolErrorInfo.reason` 保留面向用户的原始详情,不将其加入模型可见内容。 +结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。规范的 `value` 仅存在于执行期间:循环只持久化 `content`、`error` 和 `meta`,`tool/ptc-dispatch` 则存储子调用渲染后的 `content`、`isError`、可选结构化 `error` 和展示 `meta`。回放可以重现展示,却无法重建规范的中间值。可选的 `ToolErrorInfo.reason` 保留面向用户的原始详情,不将其加入模型可见内容。注册表会在以下情况设置 `info`:抛出的是 `HarnessError`(取其 `name` 和 `code`)、pre-execute 的 `deny` 自带 `info`,以及注册表自己生成的取消结果(`AbortError`,code 为 `ABORTED` 或 `ABORTED_BEFORE_DISPATCH`);`tools/execute` 包装器也可能返回带 `info` 的结果,例如超时策略的 `TOOL_TIMEOUT`。其他抛出值、guard 拒绝、审批拒绝和 post-execute block 产生的 `error` 都不含 `info`。 成功时,注册表会快照并校验函数体返回值,将其冻结,然后调用纯渲染器,并为原生与嵌套调用执行可选的展示元数据投影器。注册表会在 `tools/result` 之前另行物化持久展示字段;无效值、渲染器/投影器失败或非 JSON 展示都会转为 JSON 安全的 `isError`。因此,最终实时观察者能看到精确的执行期值,以及可安全用于后续持久追加的字段。 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index a23f1382dd..fd72f5dcae 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: 7e4516e824b1500e - zh: b0ea6f61792d98a0 + en: 29b75613157b04e1 + zh: 8088248d83c3d8be /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -84,8 +84,20 @@ en: 408ff0a5189bd7ec zh: 408ff0a5189bd7ec /tool-schema-catalog/deepseek-ai-dsh-tool-bash/bash: - en: 5efe055aae48993c - zh: e0653e51ef1344ce + en: bd3559b76dead1ea + zh: 7ec2604f73b23a31 +/tool-schema-catalog/deepseek-ai-dsh-tool-office: + en: 8b0373f31520efe7 + zh: 8b0373f31520efe7 +/tool-schema-catalog/deepseek-ai-dsh-tool-office/office-convert: + en: e63dea0f631fbb15 + zh: c564452c0ae91632 +/tool-schema-catalog/deepseek-ai-dsh-tool-office/office-recalculate: + en: 27a0ac973b1b9387 + zh: 29b4dd44a3a6b1ef +/tool-schema-catalog/deepseek-ai-dsh-tool-office/office-render: + en: b5e52a86c0a3c122 + zh: 636810250e6a0aaf /tool-schema-catalog/deepseek-ai-dsh-tool-present: en: f656575e6df094f6 zh: f656575e6df094f6 @@ -240,8 +252,8 @@ en: 8daafae6faf7898e zh: 8daafae6faf7898e /tool-schema-catalog/deepseek-ai-dsh-experimental-tool-subagent-oar/subagent-oar: - en: 6283c3023d6ea436 - zh: 9c018b8c12a3f1e7 + en: 4936c61a65f8cdb2 + zh: 5a1bfbb9738a62a9 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent: en: 841abe0dd5913ea7 zh: 841abe0dd5913ea7 @@ -249,17 +261,17 @@ en: 0e21650bbe8c71c7 zh: fe4033efb38d51a3 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent: - en: 430672da3c1a6ca6 - zh: bd3a9ab4089f9dbb + en: 9912eeda5bc40b43 + zh: 0cb70f9a0d923bee /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control: en: ab82e130c3413f04 zh: ab82e130c3413f04 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/interrupt-agent: en: a622b15cc2598d51 - zh: 48e1b05956d63e26 + zh: ece4b2af6a11c49c /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents: - en: a56168133c21c6a0 - zh: f9df1c0fe49fa225 + en: 8d50c6e19c9517ab + zh: 6ae97d046c5915db /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message: en: b53326fedeab850d zh: ecb75b6ce31da009 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 29cd20a38c..7b9753560b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -24,6 +24,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. The resolved timeout and maxSubCallsPerMinute set the total call allowance; exceeding it terminates the program. | | `@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-office` | `office_convert`, `office_recalculate`, `office_render` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.agents`, `ctx.officeKit`, `ctx.attachments and ctx.llm for office_render` | `tool/call`, `tool/result with rendered image attachments`, `temporary converted files in the Office kit output store` | - | Declared to an Agent from the step after it successfully loads an Office skill, and kept while the previous request header declares an Office tool. Admitted calls fail with the Office service's unavailable error when LibreOffice Kit is not installed. | | `@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. | @@ -40,7 +41,7 @@ This table connects model-visible tool names to the plugin package and service s | `@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`, `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` | `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 the child's name 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`, `wait_agent` | `ctx.tools`, `ctx.subagents`, `ctx.agents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | | `@deepseek-ai/dsh-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | Four task-board tools share the subagent tree’s root Session. Enabling Team mode on the root Session adds task-board guidance alongside standard subagent delegation and control tools. Standard subagent controls provide required identity independently of Team mode. | @@ -734,6 +735,123 @@ Source: [`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/sr 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-office` + +### `office_convert` + +Convert a document to another format. Return a temporary file to copy to its destination with the shell. + +```json +{ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Path to doc, docx, odt, xls, xlsx, ods, ppt, pptx, or odp; not modified." + }, + "format": { + "type": "string", + "description": "Word: pdf, docx, odt, txt. Excel: pdf, xlsx, ods, csv. PowerPoint: pdf, pptx, odp.", + "enum": [ + "pdf", + "docx", + "odt", + "txt", + "xlsx", + "ods", + "csv", + "pptx", + "odp" + ] + }, + "sheet": { + "type": "string", + "description": "Exact worksheet name for CSV only; required when the workbook has several sheets." + } + }, + "required": [ + "input", + "format" + ] +} +``` + +Source: [`packages/document/tool-office/src/index.ts`](../packages/document/tool-office/src/index.ts) + +### `office_recalculate` + +Recalculate spreadsheet formulas. Return a temporary workbook to copy to its destination with the shell. + +```json +{ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Path to an xls, xlsx, or ods workbook; not modified." + }, + "format": { + "type": "string", + "description": "Output workbook format.", + "enum": [ + "xlsx", + "ods" + ] + } + }, + "required": [ + "input", + "format" + ] +} +``` + +Source: [`packages/document/tool-office/src/index.ts`](../packages/document/tool-office/src/index.ts) + +### `office_render` + +Render document pages, slides, or worksheet areas as images you can view. + +```json +{ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Path to doc, docx, odt, xls, xlsx, ods, ppt, pptx, odp, or pdf; not modified." + }, + "pages": { + "type": "array", + "description": "Distinct one-based pages or slides; omit for all. Spreadsheets use sheet and range instead.", + "items": { + "type": "integer" + } + }, + "sheet": { + "type": "string", + "description": "Exact worksheet name; omit to render every visible sheet." + }, + "range": { + "type": "string", + "description": "A1 range, such as A1:H40; requires sheet. Omit for the used area." + }, + "dpi": { + "type": "integer", + "description": "Resolution from 24 to 600; default 144." + } + }, + "required": [ + "input" + ] +} +``` + +Source: [`packages/document/tool-office/src/index.ts`](../packages/document/tool-office/src/index.ts) + +Declared to an Agent from the step after it successfully loads an Office skill, and kept while the previous request header declares an Office tool. Admitted calls fail with the Office service's unavailable error when LibreOffice Kit is not installed. + ## `@deepseek-ai/dsh-tool-present` @@ -1994,7 +2112,7 @@ The five read-only tools hide provider cursors and authorize every result from t ### `subagent_oar` -Delegate a self-contained task to a configured external agent. Choose its runtime for each call. The child works in its own context and does not see this conversation. This tool returns a child id; a later completion notice includes its final answer. The child does not accept follow-up messages. Start independent tasks together and continue useful work while they run. When only waiting for their results remains, end your turn instead of polling. +Delegate a self-contained task to a configured external agent. Choose its runtime for each call. The child works in its own context and does not see this conversation. This tool returns the child's name; a later completion notice includes its final answer. The child does not accept follow-up messages. Start independent tasks together and continue useful work while they run. When only waiting for their results remains, end your turn instead of polling. ```json { @@ -2012,6 +2130,10 @@ Delegate a self-contained task to a configured external agent. Choose its runtim "type": "string", "description": "A short description of the delegated task, for display." }, + "name": { + "type": "string", + "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." + }, "prompt": { "type": "string", "description": "The complete, self-contained task. Include all context the external agent needs." @@ -2061,7 +2183,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its name. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes. ```json { @@ -2075,6 +2197,10 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "type": "string", "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." }, + "name": { + "type": "string", + "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." + }, "prompt": { "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." @@ -2089,7 +2215,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -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. +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 the child's name promptly; local children accept follow-ups, while external backends report their single execution automatically. @@ -2103,13 +2229,13 @@ Ask a subagent to stop its current work. This call returns without waiting for i { "type": "object", "properties": { - "agent_id": { + "agent": { "type": "string", - "description": "The id of an agent created under you: your direct child or a deeper descendant." + "description": "The name of an agent created under you: your direct child or a deeper descendant." } }, "required": [ - "agent_id" + "agent" ] } ``` @@ -2118,7 +2244,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry ids, descriptions, and status; descendant rows carry parent ids and depth from the root. You can message any other agent in the subagent tree listed as running or inactive. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation. +List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry names, descriptions, and status. A descendant's name is its path from the root, such as reviewer/checker. You can message any other agent in the subagent tree. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. corrupt, unsupported, or unavailable means this row's own descendants could not be read; the row itself is still a member you can message. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation. ```json { @@ -2137,9 +2263,9 @@ Send a message to an agent. A working agent receives it at its next step; an idl { "type": "object", "properties": { - "agent_id": { + "agent": { "type": "string", - "description": "The agent id of another member of your subagent tree, as shown by list_agents." + "description": "The name of another agent in your subagent tree, as shown by list_agents." }, "message": { "type": "string", @@ -2147,7 +2273,7 @@ Send a message to an agent. A working agent receives it at its next step; an idl } }, "required": [ - "agent_id", + "agent", "message" ] } @@ -2332,7 +2458,11 @@ List shared tasks, including readiness, owner, revision, blockers, and write-sco }, "owner": { "type": "string", - "description": "Optional exact ownerId filter; use unowned for tasks without an owner." + "description": "Optional owner agent name filter; cannot be combined with unowned." + }, + "unowned": { + "type": "boolean", + "description": "Filter tasks by whether they have no owner; cannot be combined with owner." }, "ready": { "type": "boolean", @@ -2406,7 +2536,7 @@ Compare-and-set a shared task action using the latest revision from team_task_ge }, "owner": { "type": "string", - "description": "Agent id of the root or any continuable descendant for root-only reassign; omit to unassign." + "description": "Agent name of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 8c17c2add3..d44b19f7c4 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -28,6 +28,7 @@ | `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。最终超时时间与 maxSubCallsPerMinute 决定调用总额度;超限会终止程序。 | | `@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-office` | `office_convert`、`office_recalculate`、`office_render` | `ctx.workingDirectory`、`ctx.tools`、`ctx.fs`、`ctx.agents`、`ctx.officeKit`、`ctx.attachments and ctx.llm for office_render` | `tool/call`、`tool/result with rendered image attachments`、`temporary converted files in the Office kit output store` | - | Agent 成功加载 Office 技能后,从下一步起向其声明这些工具;上一次请求头已声明 Office 工具时继续保留。未安装 LibreOffice Kit 时,已声明工具的调用以 Office 服务的 unavailable 错误失败。 | | `@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 安装。 | @@ -44,7 +45,7 @@ | `@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-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` | `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 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。每次委派都会立即返回子代理的名称;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message`、`wait_agent` | `ctx.tools`、`ctx.subagents`、`ctx.agents` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | | `@deepseek-ai/dsh-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | 四个任务板工具共享 subagent 树的根 Session。在根 Session 开启 Team 模式,会在标准 subagent 委派和控制工具之外增加任务板指导。标准 subagent 控制工具提供必需身份,不受 Team 模式影响。 | @@ -738,6 +739,123 @@ PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 + + +## `@deepseek-ai/dsh-tool-office` + +### `office_convert` + +将文档转换为另一种格式。返回一个临时文件,用 shell 将其复制到目标位置。 + +```json +{ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Path to doc, docx, odt, xls, xlsx, ods, ppt, pptx, or odp; not modified." + }, + "format": { + "type": "string", + "description": "Word: pdf, docx, odt, txt. Excel: pdf, xlsx, ods, csv. PowerPoint: pdf, pptx, odp.", + "enum": [ + "pdf", + "docx", + "odt", + "txt", + "xlsx", + "ods", + "csv", + "pptx", + "odp" + ] + }, + "sheet": { + "type": "string", + "description": "Exact worksheet name for CSV only; required when the workbook has several sheets." + } + }, + "required": [ + "input", + "format" + ] +} +``` + +来源: [`packages/document/tool-office/src/index.ts`](../packages/document/tool-office/src/index.ts) + +### `office_recalculate` + +重算电子表格公式。返回一个临时工作簿,用 shell 将其复制到目标位置。 + +```json +{ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Path to an xls, xlsx, or ods workbook; not modified." + }, + "format": { + "type": "string", + "description": "Output workbook format.", + "enum": [ + "xlsx", + "ods" + ] + } + }, + "required": [ + "input", + "format" + ] +} +``` + +来源: [`packages/document/tool-office/src/index.ts`](../packages/document/tool-office/src/index.ts) + +### `office_render` + +将文档页面、幻灯片或工作表区域渲染为你可以查看的图片。 + +```json +{ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Path to doc, docx, odt, xls, xlsx, ods, ppt, pptx, odp, or pdf; not modified." + }, + "pages": { + "type": "array", + "description": "Distinct one-based pages or slides; omit for all. Spreadsheets use sheet and range instead.", + "items": { + "type": "integer" + } + }, + "sheet": { + "type": "string", + "description": "Exact worksheet name; omit to render every visible sheet." + }, + "range": { + "type": "string", + "description": "A1 range, such as A1:H40; requires sheet. Omit for the used area." + }, + "dpi": { + "type": "integer", + "description": "Resolution from 24 to 600; default 144." + } + }, + "required": [ + "input" + ] +} +``` + +来源: [`packages/document/tool-office/src/index.ts`](../packages/document/tool-office/src/index.ts) + +Agent 成功加载 Office 技能后,从下一步起向其声明这些工具;上一次请求头已声明 Office 工具时继续保留。未安装 LibreOffice Kit 时,已声明工具的调用以 Office 服务的 unavailable 错误失败。 + ## `@deepseek-ai/dsh-tool-present` @@ -2000,7 +2118,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent_oar` -将上下文完整的任务委派给已配置的外部 agent。每次调用选择一个运行时。subagent 在独立上下文中工作,无法看到当前对话。工具返回子级 id,后续完成通知包含其最终回答。subagent 不接受后续消息。并发启动相互独立的任务,并在其运行期间继续其他有用工作。只剩等待结果时结束当前轮次,不要轮询。 +将上下文完整的任务委派给已配置的外部 agent。每次调用选择一个运行时。subagent 在独立上下文中工作,无法看到当前对话。工具返回子级的名称,后续完成通知包含其最终回答。subagent 不接受后续消息。并发启动相互独立的任务,并在其运行期间继续其他有用工作。只剩等待结果时结束当前轮次,不要轮询。 ```json { @@ -2018,6 +2136,10 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "type": "string", "description": "A short description of the delegated task, for display." }, + "name": { + "type": "string", + "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." + }, "prompt": { "type": "string", "description": "The complete, self-contained task. Include all context the external agent needs." @@ -2067,7 +2189,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。如果它结束时你仍在线,运行时会自动递送其完成通知。通知包含最终回答,除非子任务在结束前刚给你发过消息。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其名称。如果它结束时你仍在线,运行时会自动递送其完成通知。通知包含最终回答,除非子任务在结束前刚给你发过消息。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 ```json { @@ -2081,6 +2203,10 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 "type": "string", "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." }, + "name": { + "type": "string", + "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." + }, "prompt": { "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." @@ -2095,7 +2221,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 +加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。每次委派都会立即返回子代理的名称;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 @@ -2103,19 +2229,19 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `interrupt_agent` -根据 agent id 请求取消后台 agent 的当前轮次。目标可以是你的直接子级,也可以是在你下方创建的更深层 agent。对于本地 agent,只有当前轮次会停止:已经排队发给该 agent 的消息会一直搁置到后续的 send_message;它启动的 agent 会继续运行;该 agent 本身仍可接受后续操作。外部执行会永久停止,无法接收后续消息。停止请求被接受后,此调用立即返回,因此目标可能还会短暂运行;中断一个已经完成的 agent 是可接受的空操作。 +请求 subagent 停止当前工作。此调用不等待其停止即返回。之后可以用 send_message 继续本地 subagent 的对话。外部执行会永久停止,无法接收后续消息。它启动的 subagent 会继续运行。 ```json { "type": "object", "properties": { - "agent_id": { + "agent": { "type": "string", - "description": "The id of an agent created under you: your direct child or a deeper descendant." + "description": "The name of an agent created under you: your direct child or a deeper descendant." } }, "required": [ - "agent_id" + "agent" ] } ``` @@ -2124,7 +2250,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `list_agents` -列出整棵 subagent 树:根代理和所有可达的可继续后代,包括调用方自身。行包含 id、描述和状态;后代行包含父级 id 和相对根的深度。可以向树中列为 running 或 inactive 的任意其他代理发送消息。running 表示正在工作;inactive 表示当前没有轮次在执行,不表示任务完成、成功或失败。自身仍在线时,直属子代理结束会收到通知,无需反复查询其状态。使用 send_message 继续对话。 +列出整棵 subagent 树:根代理和所有可达的可继续后代,包括调用方自身。行包含名称、描述和状态。后代的名称是其相对根的路径,例如 reviewer/checker。可以向树中任意其他代理发送消息。running 表示正在工作;inactive 表示当前没有轮次在执行,不表示任务完成、成功或失败。corrupt、unsupported 或 unavailable 表示该行自身的后代目录无法读取,该行本身仍是可发送消息的成员。自身仍在线时,直属子代理结束会收到通知,无需反复查询其状态。使用 send_message 继续对话。 ```json { @@ -2143,9 +2269,9 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 { "type": "object", "properties": { - "agent_id": { + "agent": { "type": "string", - "description": "The agent id of another member of your subagent tree, as shown by list_agents." + "description": "The name of another agent in your subagent tree, as shown by list_agents." }, "message": { "type": "string", @@ -2153,7 +2279,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } }, "required": [ - "agent_id", + "agent", "message" ] } @@ -2338,7 +2464,11 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "owner": { "type": "string", - "description": "Optional exact ownerId filter; use unowned for tasks without an owner." + "description": "Optional owner agent name filter; cannot be combined with unowned." + }, + "unowned": { + "type": "boolean", + "description": "Filter tasks by whether they have no owner; cannot be combined with owner." }, "ready": { "type": "boolean", @@ -2412,7 +2542,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "owner": { "type": "string", - "description": "Agent id of the root or any continuable descendant for root-only reassign; omit to unassign." + "description": "Agent name of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ diff --git a/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.i18n.yaml new file mode 100644 index 0000000000..5cba8520c1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/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.4/sandbox-unsupported-policy-error/guide.md +/: + en: d7c1fc703991b92a + zh: f5cddebaa6b311a0 +/remove-sandboxunsupportedpolicyerror-imports: + en: fc8fa3704fa036ce + zh: e96b09c9882f760b +/remove-sandboxunsupportedpolicyerror-imports/change: + en: 3ca1bb8d19b7781e + zh: 9f3800c847791344 +/remove-sandboxunsupportedpolicyerror-imports/migration: + en: 29b8d3231a83e606 + zh: 24416c92a4dc9b71 diff --git a/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md new file mode 100644 index 0000000000..35605b3804 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The sandbox package removes SandboxUnsupportedPolicyError; Windows processes ignore additional writable roots." +--- + +# Remove SandboxUnsupportedPolicyError imports + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-sandbox` no longer exports `SandboxUnsupportedPolicyError`. Windows subprocess confinement ignores additional writable directories instead of rejecting execution with `SANDBOX_UNSUPPORTED_POLICY`. The harness `write` and `edit` tools still honor those directories; Windows processes retain workspace and private-temp access under `workspace-write`. + +## Migration + +1. Remove imports and error handlers specific to `SandboxUnsupportedPolicyError` or `SANDBOX_UNSUPPORTED_POLICY`. +2. Use the harness file tools for writes to additional directories on Windows. Do not assume a successful process launch grants those directories. +3. Rebuild the consumer and verify that workspace commands run while extra directories remain accessible only through the file tools. diff --git a/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.zh.md new file mode 100644 index 0000000000..4413d95e11 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "sandbox 包移除 SandboxUnsupportedPolicyError;Windows 进程忽略额外可写根目录。" +--- + +# 移除 SandboxUnsupportedPolicyError 导入 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-sandbox` 不再导出 `SandboxUnsupportedPolicyError`。Windows 子进程隔离忽略额外可写目录,不再以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝执行。Harness 的 `write` 与 `edit` 工具仍执行这些目录的授权;Windows 进程在 `workspace-write` 下保留工作区与私有临时目录访问权限。 + +## 迁移 + +1. 移除针对 `SandboxUnsupportedPolicyError` 或 `SANDBOX_UNSUPPORTED_POLICY` 的导入与错误处理。 +2. 在 Windows 上使用 Harness 文件工具写入额外目录。不要假定进程启动成功就意味着这些目录已获授权。 +3. 重新构建消费方,验证工作区命令可以运行,额外目录仍仅能通过文件工具访问。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.i18n.yaml new file mode 100644 index 0000000000..b43a0db6e7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/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.2-alpha.2/llm-error-builtin-codes/guide.md +/: + en: a3bc51b72528488c + zh: 1feb265c92492487 +/new-llmerror-accepts-only-built-in-failure-codes: + en: 390a9a983fc10296 + zh: 2ce8cbc8c8661acf +/new-llmerror-accepts-only-built-in-failure-codes/change: + en: db9bc6fe227f0d41 + zh: 9ef71a6e7a2214be +/new-llmerror-accepts-only-built-in-failure-codes/migration: + en: 605b8836fd1872e9 + zh: 7cd1e58bb9cdb49e diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md new file mode 100644 index 0000000000..c1ab6a5494 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "`new LlmError` accepts only built-in failure codes; other codes go through `LlmError.fromFailure`." +--- + +# `new LlmError` accepts only built-in failure codes + +English | [中文](guide.zh.md) + +## Change + +In `@deepseek-ai/dsh-llm`, the `code` parameter of the `LlmError` constructor was `string` and is now `BuiltinLlmFailureCode`: a key of `LLM_FAILURE_CODES` or `HTTP_`. Runtime behavior and `LlmFailure.code` are unchanged. TypeScript code that constructs `LlmError` with any other code, such as an out-of-tree provider adapter or a test double, no longer compiles. + +## Migration + +1. Replace each `new LlmError(message, code, options)` whose `code` is not built-in with `LlmError.fromFailure({ message, code, ...facts }, { cause })`, where `facts` holds any `status`, `providerRetryAfterMs`, `requestId`, or `offloadImages`. +2. Re-raise a failure received from a stream with `LlmError.fromFailure(finish.failure)` instead of passing `finish.failure.code` to the constructor. +3. Confirm with `tsc` that no `LlmError` construction reports a code that is not assignable to `BuiltinLlmFailureCode`. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.zh.md new file mode 100644 index 0000000000..7d7c1fde19 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "`new LlmError` 只接受内置失败 code;其他 code 改用 `LlmError.fromFailure`。" +--- + +# `new LlmError` 只接受内置失败 code + +[English](guide.md) | 中文 + +## 变更 + +在 `@deepseek-ai/dsh-llm` 中,`LlmError` 构造函数的 `code` 参数原为 `string`,现为 `BuiltinLlmFailureCode`:`LLM_FAILURE_CODES` 的键或 `HTTP_`。运行时行为和 `LlmFailure.code` 不变。用其他 code 构造 `LlmError` 的 TypeScript 代码(例如仓库外的提供方适配器或测试替身)将无法通过编译。 + +## 迁移 + +1. 把每个 `code` 不是内置值的 `new LlmError(message, code, options)` 改为 `LlmError.fromFailure({ message, code, ...facts }, { cause })`,其中 `facts` 包含需要的 `status`、`providerRetryAfterMs`、`requestId` 或 `offloadImages`。 +2. 重新抛出从流中收到的失败时,使用 `LlmError.fromFailure(finish.failure)`,而不是把 `finish.failure.code` 传给构造函数。 +3. 用 `tsc` 确认没有任何 `LlmError` 构造报告 code 不能赋值给 `BuiltinLlmFailureCode`。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml new file mode 100644 index 0000000000..bfb46f8f3d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/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.2-alpha.2/session-header-menu/guide.md +/: + en: 54b02da822ff0fbb + zh: 1d43d9775ebb92a8 +/session-header-menus-and-export-dialogs: + en: d7c751cc3dbc57b7 + zh: 3d08043e08ccf406 +/session-header-menus-and-export-dialogs/change: + en: cf5c9760f279f709 + zh: cb640560cc139fa9 +/session-header-menus-and-export-dialogs/migration: + en: 4a12455c8862476f + zh: 49f775d6fcc10751 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md new file mode 100644 index 0000000000..cc694a6a7f --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Custom Web compositions must provide context menus for Session actions, and export dialogs now remain visible across Session switches." +--- + +# Session Header menus and export dialogs + +English | [中文](guide.zh.md) + +## Change + +The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to contribute copy, download, and feedback actions. Custom Web compositions without a context-menu provider must add one or these client actions cannot activate. The default Web composition already includes it. The Header More button belongs to `ui-conversation`, so removing the export plugin does not remove other registered Header actions such as Open in. The button hides when no Header-menu providers are registered. + +Download dialogs are mounted in the root `shell.overlay`, rather than a Session Header. They identify the exported Session and remain visible when the user switches Sessions. Downloads may run concurrently, but dialogs appear one at a time in request order; closing one shows the next and does not cancel its download. Host export routes and the `/export` command are unchanged. + +## Migration + +1. Add `@deepseek-ai/dsh-client-ui-context-menu` to custom Web compositions that lack it, with the menu package's required services. See [context-menu composition](../../../../packages/client/ui-context-menu/README.md#use-this-package). Keep `ui-conversation` for the Session Header and the shell's root overlay for download feedback. Default Web profiles need no configuration change. +2. Custom client code using `SessionLogDownloadDialogProps` must use root-overlay runtime props instead of Session-header props and remove the `request` injection. `SessionLogDownloadEntry` includes an `order` value; the dialog reads Session identities and ordering from `bySession`. +3. Restart the profile. Confirm that Header More contains the registered actions, Open in remains after disabling the export plugin, and starting downloads for two Sessions presents their dialogs in request order even after switching Sessions. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md new file mode 100644 index 0000000000..6c410f8e81 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "自定义 Web 组合必须为会话操作提供右键菜单服务,导出弹窗在切换 Session 后仍保持显示。" +--- + +# 会话头部菜单与导出弹窗 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 提供复制、下载和反馈操作。没有右键菜单提供者的自定义 Web 组合必须补充,否则这些客户端操作无法激活。默认 Web 组合已包含提供者。头部更多按钮由 `ui-conversation` 持有,因此移除导出插件不会移除其他已注册的头部操作,例如在应用中打开。没有注册任何头部菜单提供者时,按钮隐藏。 + +下载弹窗挂载于根级 `shell.overlay`,不再位于 Session Header。弹窗标明正在导出的 Session,切换 Session 后仍然显示。多个下载可以并行进行,但弹窗按发起顺序一次展示一个;关闭当前弹窗会展示下一个,不取消其下载。Host 导出路由及 `/export` 命令不变。 + +## 迁移 + +1. 自定义 Web 组合缺少提供者时,添加 `@deepseek-ai/dsh-client-ui-context-menu` 并提供菜单包要求的服务。参见[右键菜单组合](../../../../packages/client/ui-context-menu/README.zh.md#use-this-package)。保留 `ui-conversation` 以提供会话头部,以及 shell 的根级 overlay 以展示下载反馈。默认 Web profile 无需修改配置。 +2. 使用 `SessionLogDownloadDialogProps` 的自定义客户端代码需改用根级 overlay runtime props,不再使用 Session Header props,并移除 `request` 注入。`SessionLogDownloadEntry` 包含 `order` 值;弹窗从 `bySession` 读取 Session 身份与顺序。 +3. 重启 profile,确认头部更多菜单包含已注册的操作,禁用导出插件后仍可在应用中打开,并确认两个 Session 的下载弹窗按发起顺序显示,切换 Session 后仍然保留。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.i18n.yaml new file mode 100644 index 0000000000..675e8c8f74 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/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.2-alpha.3/office-kit-package/guide.md +/: + en: 0e3fae218b0fe86f + zh: 31fa1f4ac5cfffe6 +/deepseek-ai-dsh-office-kit-replaces-deepseek-ai-dsh-office-to-pdf: + en: e9c8bf48217b3e1c + zh: 710a0cd0489a65b7 +/deepseek-ai-dsh-office-kit-replaces-deepseek-ai-dsh-office-to-pdf/change: + en: 885088211bc994ac + zh: ee8192f950f2597b +/deepseek-ai-dsh-office-kit-replaces-deepseek-ai-dsh-office-to-pdf/migration: + en: 9871a035772e8bbd + zh: 53f05c66de3ceb0e diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.md b/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.md new file mode 100644 index 0000000000..94bac19e39 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.md @@ -0,0 +1,34 @@ +--- +kind: upgrade-guide +description: "`@deepseek-ai/dsh-office-kit` replaces `@deepseek-ai/dsh-office-to-pdf`, renaming its service, Remote methods, exported types, and configuration row." +--- + +# `@deepseek-ai/dsh-office-kit` replaces `@deepseek-ai/dsh-office-to-pdf` + +English | [中文](guide.zh.md) + +## Change + +The Office conversion provider moved from `@deepseek-ai/dsh-office-to-pdf` to `@deepseek-ai/dsh-office-kit`. Compositions, plugins, and Clients that name the old package, service, Remote methods, or exported symbols are affected: + +| Old | New | +|---|---| +| `cordis.yml` row `id: office-to-pdf`, `name: '@deepseek-ai/dsh-office-to-pdf'` | `id: office-kit`, `name: '@deepseek-ai/dsh-office-kit'`; configuration keys are unchanged | +| `ctx.officeToPdf` | `ctx.officeKit` | +| Remote `officeToPdf/render`, `officeToPdf/generation` | `officeKit/render`, `officeKit/generation` | +| `OfficeToPdf` | `OfficeKit` | +| `OfficeToPdfError`, `OfficeToPdfErrorCode` | `OfficeKitError`, `OfficeKitErrorCode` | +| `OfficeToPdfGeneration` | `OfficeKitGeneration` | +| `OfficeToPdfRequest`, `OfficeToPdfResult`, `OfficeToPdfPriority`, `OfficeToPdfKey` | `OfficeKitRequest`, `OfficeKitResult`, `OfficeKitPriority`, `OfficeKitKey` | + +The `document-render/failed` Remote error, `RenderedDocumentBytes`, `OfficeSourceKey`, and `OfficeExtension` keep their names. The `/types` and `/remote` entries remain. + +The provider previously failed to load when LibreOffice Kit was absent. It now registers without Kit; each conversion that needs a converter rejects with `OfficeKitError` code `unavailable` and locates Kit again, so installing Kit later takes effect without reloading the provider. + +## Migration + +1. In `cordis.yml`, patches, and overlays, change the row `id: office-to-pdf` to `id: office-kit` and its `name` to `'@deepseek-ai/dsh-office-kit'`. Keep its `config` block unchanged. +2. Replace the dependency `@deepseek-ai/dsh-office-to-pdf` with `@deepseek-ai/dsh-office-kit` in `package.json`, and update import specifiers, including `/types` and `/remote`. +3. Rename `ctx.officeToPdf`, Remote method names, and the exported symbols listed above. +4. Code that treated a load failure of the conversion row as "Kit absent" now checks for `OfficeKitError` with code `unavailable` from conversion instead. +5. Confirm the migration by searching the project for `office-to-pdf`, `officeToPdf`, and `OfficeToPdf`, then opening a DOCX file in the Web document preview. diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.zh.md new file mode 100644 index 0000000000..be5727f45a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-kit-package/guide.zh.md @@ -0,0 +1,34 @@ +--- +kind: upgrade-guide +description: "`@deepseek-ai/dsh-office-kit` 取代 `@deepseek-ai/dsh-office-to-pdf`,其服务、Remote 方法、导出类型和配置行随之改名。" +--- + +# `@deepseek-ai/dsh-office-kit` 取代 `@deepseek-ai/dsh-office-to-pdf` + +[English](guide.md) | 中文 + +## 变更 + +Office 转换提供方从 `@deepseek-ai/dsh-office-to-pdf` 移到 `@deepseek-ai/dsh-office-kit`。引用旧包、服务、Remote 方法或导出符号的组合、插件和 Client 受到影响: + +| 旧名 | 新名 | +|---|---| +| `cordis.yml` 行 `id: office-to-pdf`、`name: '@deepseek-ai/dsh-office-to-pdf'` | `id: office-kit`、`name: '@deepseek-ai/dsh-office-kit'`;配置项不变 | +| `ctx.officeToPdf` | `ctx.officeKit` | +| Remote `officeToPdf/render`、`officeToPdf/generation` | `officeKit/render`、`officeKit/generation` | +| `OfficeToPdf` | `OfficeKit` | +| `OfficeToPdfError`、`OfficeToPdfErrorCode` | `OfficeKitError`、`OfficeKitErrorCode` | +| `OfficeToPdfGeneration` | `OfficeKitGeneration` | +| `OfficeToPdfRequest`、`OfficeToPdfResult`、`OfficeToPdfPriority`、`OfficeToPdfKey` | `OfficeKitRequest`、`OfficeKitResult`、`OfficeKitPriority`、`OfficeKitKey` | + +Remote 错误 `document-render/failed`、`RenderedDocumentBytes`、`OfficeSourceKey` 和 `OfficeExtension` 保持原名。`/types` 和 `/remote` 入口仍然保留。 + +原先 LibreOffice Kit 缺失时提供方加载失败。现在没有 Kit 时提供方照常注册;每次需要转换器的转换都以代码为 `unavailable` 的 `OfficeKitError` 拒绝,并重新定位 Kit,因此之后安装的 Kit 无需重新加载提供方即可生效。 + +## 迁移 + +1. 在 `cordis.yml`、patch 和 overlay 中,把 `id: office-to-pdf` 行改为 `id: office-kit`,并把其 `name` 改为 `'@deepseek-ai/dsh-office-kit'`。`config` 块保持不变。 +2. 在 `package.json` 中用 `@deepseek-ai/dsh-office-kit` 替换依赖 `@deepseek-ai/dsh-office-to-pdf`,并更新导入路径,包括 `/types` 和 `/remote`。 +3. 按上表重命名 `ctx.officeToPdf`、Remote 方法名和导出符号。 +4. 原先把转换行加载失败视为“Kit 缺失”的代码,改为检查转换返回的、代码为 `unavailable` 的 `OfficeKitError`。 +5. 在项目中搜索 `office-to-pdf`、`officeToPdf` 和 `OfficeToPdf` 确认已无残留,然后在 Web 文档预览中打开一个 DOCX 文件,确认迁移完成。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.i18n.yaml new file mode 100644 index 0000000000..917ca71205 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/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.2-alpha.3/office-operation-tools/guide.md +/: + en: f25812af9f761878 + zh: 3a8dbcde68ed75c9 +/office-operation-skills-are-replaced-by-office-tools: + en: 6c5aaef399e15878 + zh: 92c4ae1a8d812790 +/office-operation-skills-are-replaced-by-office-tools/change: + en: 1d12b14e002bcf3b + zh: ac844c2aeb9f9053 +/office-operation-skills-are-replaced-by-office-tools/migration: + en: 9646620e0c4636c3 + zh: be896458a90937ba diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.md b/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.md new file mode 100644 index 0000000000..ec051d8535 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.md @@ -0,0 +1,49 @@ +--- +kind: upgrade-guide +description: "The `office-render`, `office-recalculate`, and `office-export-pdf` skills, the skill-office `node` and `cli` keys, and two Office CLI environment variables are removed; Office operations use skill-activated tools." +--- + +# Office operation skills are replaced by Office tools + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-skill-office` no longer registers the `office-render`, `office-recalculate`, and `office-export-pdf` skills; it registers the `office` router and the `office-docx`, `office-pptx`, and `office-xlsx` authoring skills. After an Office skill loads, the model calls [`office_render`, `office_convert`, and `office_recalculate`](../../../../packages/document/tool-office/README.md) from the following step. + +Configuration changes: + +| Removed | Replacement | +|---|---| +| `skill-office` keys `node` and `cli` | None; Office tools convert in the Host | +| `DSH_BUNDLED_OFFICE_CLI` and `DSH_BUNDLED_OFFICE_NODE` | None; no component reads them | +| `DSH_BUNDLED_PRIMARY_RUNTIME` | `DSH_PRIMARY_RUNTIME` | + +`skill-office` gains `pythonEnvironment`. Its default, `'workspace-dependencies'`, directs the Python skills to `load_workspace_dependencies`; a composition without `@deepseek-ai/dsh-tool-workspace-dependencies` must set `'user'`. [Office skill resources](../office-skill-resources/guide.md) covers the removal of `DSH_BUNDLED_OFFICE_SKILLS`. + +The Office tools parse untrusted documents with the native LibreOffice Kit engine in the Host process, outside the shell sandbox and PTC permissions, with the Host user's permissions; the filesystem backend still authorizes inputs. The `office-kit` row gains `outputDirectory` (default `$DSH_HOME/office-kit/outputs`), `outputRetentionMs`, and `maxOutputStoreBytes`. + +The Web profile, and the SDK profile without a primary runtime, list the Office skills in the first request context and `/` menu. + +This affects users and prompts of the removed skills, compositions and launchers setting the removed keys or variables, `assetRoot` skill copies, saved `standard` or `ptc` presets, deployments relying on the shell sandbox for document parsing, and tests pinning the first request context or `/` menu. + +## Migration + +1. Replace each removed skill with loading `office` and calling the matching tool: + - `office-render`: `office_render`; + - `office-recalculate`: `office_recalculate` with `format: "xlsx"` or `"ods"`; + - `office-export-pdf`: `office_convert` with `format: "pdf"`. +2. Remove `node` and `cli` from every `skill-office` row in `cordis.yml`, patches, and overlays. Set `pythonEnvironment: user` unless the composition mounts `@deepseek-ai/dsh-tool-workspace-dependencies`. +3. Compositions that mount `@deepseek-ai/dsh-skill-office` without the shipped bundles also mount `@deepseek-ai/dsh-office-kit` and `@deepseek-ai/dsh-tool-office`. +4. In launchers, stop setting `DSH_BUNDLED_OFFICE_CLI` and `DSH_BUNDLED_OFFICE_NODE`, and rename `DSH_BUNDLED_PRIMARY_RUNTIME` to `DSH_PRIMARY_RUNTIME`. The Python SDK prefers a set or inherited `DSH_PRIMARY_RUNTIME`, even empty, over its download cache; unset it to use the cache. +5. In a copy referenced by `assetRoot`, replace the four remaining folders with the installed package's `assets/` and delete the three removed skill folders. +6. A `standard` or `ptc` preset saved from the Agent Preset editor replaces the shipped plugin list, so its Sessions lack the Office tools. Add this row to the saved list, or to `config.plugins` of the `preset-standard` or `preset-ptc` row in `$DSH_HOME/profiles//cordis.patch.yml` (``: `web` or `desktop`): + + ```yaml + - id: tool-office + name: '@deepseek-ai/dsh-tool-office' + ``` + +7. Deployments that require sandboxed document parsing omit `@deepseek-ai/dsh-tool-office`. +8. Update expected first request contexts and `/` menus with the Office skills. +9. Confirm: in a new `standard` or `ptc` Session, send `/office`, then a separate PDF export request; it produces an `office_convert` call that returns a temporary PDF path or the `unavailable` message. diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.zh.md new file mode 100644 index 0000000000..5fc9a4c495 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-operation-tools/guide.zh.md @@ -0,0 +1,49 @@ +--- +kind: upgrade-guide +description: "删除 `office-render`、`office-recalculate` 和 `office-export-pdf` 技能、skill-office 的 `node` 和 `cli` 配置键,以及两个 Office CLI 环境变量;Office 操作改用由技能激活的工具。" +--- + +# Office 操作技能由 Office 工具取代 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-skill-office` 不再注册 `office-render`、`office-recalculate` 和 `office-export-pdf` 技能,只注册 `office` 路由技能,以及 `office-docx`、`office-pptx` 和 `office-xlsx` 编写技能。加载 Office 技能后,模型从下一步起调用 [`office_render`、`office_convert` 和 `office_recalculate`](../../../../packages/document/tool-office/README.zh.md)。 + +配置变化: + +| 已删除 | 替代 | +|---|---| +| `skill-office` 配置键 `node` 和 `cli` | 无;Office 工具在 Host 中转换 | +| `DSH_BUNDLED_OFFICE_CLI` 和 `DSH_BUNDLED_OFFICE_NODE` | 无;不再有组件读取它们 | +| `DSH_BUNDLED_PRIMARY_RUNTIME` | `DSH_PRIMARY_RUNTIME` | + +`skill-office` 新增 `pythonEnvironment`。默认值 `'workspace-dependencies'` 让 Python 技能调用 `load_workspace_dependencies`;没有挂载 `@deepseek-ai/dsh-tool-workspace-dependencies` 的组合必须设为 `'user'`。`DSH_BUNDLED_OFFICE_SKILLS` 的删除见 [Office 技能资源](../office-skill-resources/guide.zh.md)。 + +Office 工具在 Host 进程内用原生 LibreOffice Kit 引擎解析不可信的文档,不受 shell 沙箱和 PTC 权限约束,并具有 Host 用户的权限;输入仍由文件系统后端授权。`office-kit` 条目新增 `outputDirectory`(默认 `$DSH_HOME/office-kit/outputs`)、`outputRetentionMs` 和 `maxOutputStoreBytes`。 + +Web profile,以及没有 primary runtime 的 SDK profile,会在首次请求上下文和 `/` 菜单中列出 Office 技能。 + +受影响的有:使用已删除技能的用户和提示词,设置了已删除配置键或环境变量的组合和启动器,`assetRoot` 技能副本,已保存的 `standard` 或 `ptc` 预设,依赖 shell 沙箱解析文档的部署,以及固定了首次请求上下文或 `/` 菜单的测试。 + +## 迁移 + +1. 把每个已删除的技能替换为加载 `office` 并调用对应工具: + - `office-render`:`office_render`; + - `office-recalculate`:`office_recalculate`,`format` 为 `"xlsx"` 或 `"ods"`; + - `office-export-pdf`:`office_convert`,`format` 为 `"pdf"`。 +2. 从 `cordis.yml`、patch 和 overlay 的所有 `skill-office` 行中删除 `node` 和 `cli`。除非组合挂载了 `@deepseek-ai/dsh-tool-workspace-dependencies`,否则设置 `pythonEnvironment: user`。 +3. 不使用随发行版 bundle、自行挂载 `@deepseek-ai/dsh-skill-office` 的组合,同时挂载 `@deepseek-ai/dsh-office-kit` 和 `@deepseek-ai/dsh-tool-office`。 +4. 在启动器中不再设置 `DSH_BUNDLED_OFFICE_CLI` 和 `DSH_BUNDLED_OFFICE_NODE`,并把 `DSH_BUNDLED_PRIMARY_RUNTIME` 改名为 `DSH_PRIMARY_RUNTIME`。Python SDK 优先使用环境中设置或继承的 `DSH_PRIMARY_RUNTIME`(空值也一样),其次才是下载缓存;要使用缓存,请取消设置该变量。 +5. 在 `assetRoot` 引用的副本中,用已安装包 `assets/` 下的对应目录替换其余四个目录,并删除三个已删除技能的目录。 +6. 在 Agent 预设编辑器中保存过的 `standard` 或 `ptc` 预设会替换随发行版提供的插件列表,因此其 Session 没有 Office 工具。把下面这一行加入已保存的列表,或加入 `$DSH_HOME/profiles//cordis.patch.yml` 中 `preset-standard` 或 `preset-ptc` 行的 `config.plugins`(``:`web` 或 `desktop`): + + ```yaml + - id: tool-office + name: '@deepseek-ai/dsh-tool-office' + ``` + +7. 需要在沙箱中解析文档的部署不挂载 `@deepseek-ai/dsh-tool-office`。 +8. 更新首次请求上下文和 `/` 菜单的预期结果,使其包含 Office 技能。 +9. 确认:在 `standard` 或 `ptc` 的新 Session 中发送 `/office`,再另发一条导出 PDF 的请求;该请求会产生一次 `office_convert` 调用,返回临时 PDF 路径或 `unavailable` 消息。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.i18n.yaml new file mode 100644 index 0000000000..4ec7e5158a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/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.2-alpha.3/office-skill-resources/guide.md +/: + en: be76cf53968415f9 + zh: e641c2ede9b53fcd +/office-skills-load-from-the-packaged-assets: + en: 9dadceb35e48b91c + zh: 5c7103427581a6bd +/office-skills-load-from-the-packaged-assets/change: + en: da134862e17a7bd2 + zh: dc31dc816c669add +/office-skills-load-from-the-packaged-assets/migration: + en: e9c9fc8e2ec5a9b0 + zh: e736d57c47fb111c diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.md b/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.md new file mode 100644 index 0000000000..6764ea4ff6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.md @@ -0,0 +1,36 @@ +--- +kind: upgrade-guide +description: "`DSH_BUNDLED_OFFICE_SKILLS` and the `office-skills/` resource directories are removed; Office skills load from the `skill-office` package's assets." +--- + +# Office skills load from the packaged assets + +English | [中文](guide.zh.md) + +## Change + +The Web and SDK profiles no longer set `assetRoot` on the `skill-office` row, so [`@deepseek-ai/dsh-skill-office`](../../../../packages/skill/skill-office/README.md) reads the Office skills from its packaged `assets/` in every deployment: + +| Removed | Replacement | +|---|---| +| `DSH_BUNDLED_OFFICE_SKILLS` | `assetRoot` in a profile patch, or same-name filesystem skills | +| Desktop `resources/runtime/office-skills/` | The skill package inside `resources/app.asar` | +| Python runtime wheel `-/office-skills/` | The skill package inside the runtime executable | +| `office-skills/` beside `primary-runtime/` in `pnpm run prepare:primary-runtime` output and the downloaded authoring payload | None | + +`pythonEnvironment` and its values are unchanged. This affects launchers that set `DSH_BUNDLED_OFFICE_SKILLS`, tools that read or edit skill files in those directories, and container images that copy `office-skills/` beside `primary-runtime/`. + +## Migration + +1. Stop setting `DSH_BUNDLED_OFFICE_SKILLS`. To keep a custom skill directory, set `assetRoot` in `$DSH_HOME/profiles//cordis.patch.yml`. A patch replaces the row's whole `config`, so also set `pythonEnvironment: user` unless the profile mounts `workspace-dependencies`, which requires a nonempty `DSH_PRIMARY_RUNTIME` in Desktop and the SDK: + + ```yaml + - id: skill-office + config: + assetRoot: /absolute/path/to/office-skills + pythonEnvironment: user + ``` + +2. Alternatively, place a skill named `office`, `office-docx`, `office-pptx`, or `office-xlsx` in a [project or user skill directory](../../../../packages/skill/skill-filesystem/README.md); filesystem skills take precedence over the bundled skills with the same name. +3. Remove steps that copy or read `office-skills/` in Desktop resources, Python wheels, or prepared primary-runtime payloads. Containers copy only `primary-runtime/` and keep `DSH_PRIMARY_RUNTIME` pointing at it. +4. Confirm: in a new Session, load `office-docx`; its result names a base directory under the skill package's `assets/`, under the configured `assetRoot`, or in the overriding skill directory. diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.zh.md new file mode 100644 index 0000000000..687cb09a10 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/office-skill-resources/guide.zh.md @@ -0,0 +1,36 @@ +--- +kind: upgrade-guide +description: "`DSH_BUNDLED_OFFICE_SKILLS` 和 `office-skills/` 资源目录被删除;Office 技能从 `skill-office` 包的 assets 加载。" +--- + +# Office 技能从包内 assets 加载 + +[English](guide.md) | 中文 + +## 变更 + +Web 和 SDK profile 不再为 `skill-office` 行设置 `assetRoot`,因此 [`@deepseek-ai/dsh-skill-office`](../../../../packages/skill/skill-office/README.zh.md) 在所有部署中都从包内 `assets/` 读取 Office 技能: + +| 已删除 | 替代 | +|---|---| +| `DSH_BUNDLED_OFFICE_SKILLS` | profile patch 中的 `assetRoot`,或同名文件系统技能 | +| Desktop `resources/runtime/office-skills/` | `resources/app.asar` 内的技能包 | +| Python 运行时 wheel 的 `-/office-skills/` | 运行时可执行程序内的技能包 | +| `pnpm run prepare:primary-runtime` 输出和下载的创作载荷中与 `primary-runtime/` 同级的 `office-skills/` | 无 | + +`pythonEnvironment` 及其取值不变。受影响的有:设置 `DSH_BUNDLED_OFFICE_SKILLS` 的启动器,读取或编辑上述目录中技能文件的工具,以及把 `office-skills/` 复制到 `primary-runtime/` 旁边的容器镜像。 + +## 迁移 + +1. 不再设置 `DSH_BUNDLED_OFFICE_SKILLS`。要继续使用自定义技能目录,在 `$DSH_HOME/profiles//cordis.patch.yml` 中设置 `assetRoot`。patch 会替换该行的整个 `config`,因此除非 profile 挂载了 `workspace-dependencies`(Desktop 和 SDK 都要求 `DSH_PRIMARY_RUNTIME` 非空),还要设置 `pythonEnvironment: user`: + + ```yaml + - id: skill-office + config: + assetRoot: /absolute/path/to/office-skills + pythonEnvironment: user + ``` + +2. 也可以在[项目或用户技能目录](../../../../packages/skill/skill-filesystem/README.zh.md)中放置名为 `office`、`office-docx`、`office-pptx` 或 `office-xlsx` 的技能;文件系统技能优先于同名内置技能。 +3. 删除从 Desktop 资源、Python wheel 或准备好的主运行时载荷中复制或读取 `office-skills/` 的步骤。容器只复制 `primary-runtime/`,并让 `DSH_PRIMARY_RUNTIME` 继续指向它。 +4. 确认:在新 Session 中加载 `office-docx`,其结果给出的基础目录位于技能包的 `assets/` 下、所配置的 `assetRoot` 下,或覆盖它的技能目录中。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.i18n.yaml new file mode 100644 index 0000000000..30dd0072b8 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/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.2-alpha.3/subagent-names/guide.md +/: + en: 9d10fcdf4af74152 + zh: 1e932a7fe192df48 +/subagent-tools-address-agents-by-name: + en: 7aad2f3764a30b45 + zh: 4e24d2370e54bc37 +/subagent-tools-address-agents-by-name/change: + en: 6b457593371cf01e + zh: 730d30aad31ebd91 +/subagent-tools-address-agents-by-name/migration: + en: d416c6b1cef47655 + zh: edcae0ecc55dcfe5 diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.md b/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.md new file mode 100644 index 0000000000..1d114a3e4d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "Subagent control and Team task tools address agents by name, list_agents rows change, and subagent catalogs write payload version 3." +--- + +# Subagent tools address agents by name + +English | [中文](guide.zh.md) + +## Change + +New subagents have sibling-unique names in their creator's `subagent/catalog`. The root is `lead`; descendant names are paths such as `reviewer/checker`. The [subagent runtime](../../../../packages/subagent/subagent/README.md#agent-names) defines naming rules. Historical children keep their Session ids. Scripts, PTC, SDK callers, and transcripts must adopt these changes: + +- `send_message({ agent_id, message })` becomes `send_message({ agent, message })`, and `interrupt_agent({ agent_id })` becomes `interrupt_agent({ agent })`. `agent` accepts a name or a Session id. A call that still passes `agent_id` fails argument validation. +- `subagent`, `subagent_fork`, and `subagent_oar` accept an optional `name`. They return `started subagent ` with result `{ kind: 'activation', name }`. +- `list_agents` returns `{ name, status, description? }` rows instead of rows with `kind`, `id`, `label`, `parent`, `depth`, and `reason`. Unreadable branches report `corrupt`, `unsupported`, or `unavailable` as `status`. Text rows read ` [] — `. +- Team task results replace `ownerId` with `owner`, the owner's name. `team_task_list` and `team_task_update` accept a name as `owner`. To list tasks without an owner, use `unowned: true` instead of `owner: "unowned"`. +- Local and parent-delivered external creation write `subagent/catalog` payload version 3. Releases before this one reject catalogs that contain it, so they cannot open Sessions that create subagents after upgrading. See the [persistence acknowledgement](../../../persistence-changes/2026-11-02-subagent-catalog-names.md). +- `SubagentActivation` has a required `name` field, and `SubagentActivationSpec` accepts an optional `name`. + +## Migration + +1. In scripts, PTC programs, and SDK calls, rename the `agent_id` argument of `send_message` and `interrupt_agent` to `agent`. Pass the name that `list_agents` or the start receipt reports; a saved Session id still works. +2. Update parsers of `list_agents` output to read `name`, `status`, and `description`. Only complete named paths encode creators: the prefix before the last `/`, or `lead` without `/`. Unnamed children or ancestors cause Session-id fallback; these rows expose neither creator nor depth. +3. Update Team task consumers to read `owner` instead of `ownerId`. Host code that calls `ctx.agentTeams` still reads `ownerId`; display `ownerName ?? ownerId`. New assignments persist optional `ownerName`; historical records remain unchanged. Older projections reject named tasks; see the [acknowledgement](../../../persistence-changes/2026-11-02-team-task-owner-names.md). +4. Read `name` instead of `subagentId` from delegation tool results. Update recorded transcripts or fixtures that contain `agent_id` arguments, `started subagent ` receipts, identity runtime context text, `Agent sent a message` signatures, `Background subagent ` notices, or the earlier `list_agents` rows. Signatures and notices now use names. +5. Add `name` when constructing `SubagentActivation` values. Code that reads catalog rows can read the optional `name` field. +6. Upgrade every process that opens the same Session store before creating subagents. Older releases cannot open Sessions that contain catalog payload version 3. +7. To confirm the change, start a subagent with `name: "reviewer"` and check that the receipt reads `started subagent reviewer`, that `list_agents` lists `reviewer`, and that `send_message({ agent: "reviewer", message })` succeeds. diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.zh.md new file mode 100644 index 0000000000..e6e65415b3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/subagent-names/guide.zh.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "Subagent 控制工具和 Team 任务工具按名称寻址 agent,list_agents 行格式改变,subagent 目录写入第 3 版载荷。" +--- + +# Subagent 工具按名称寻址 agent + +[English](guide.md) | 中文 + +## 变更 + +新 subagent(子智能体)在创建者的 `subagent/catalog` 中拥有同级唯一的名称。根为 `lead`;后代名称是 `reviewer/checker` 这样的路径。[subagent 运行时](../../../../packages/subagent/subagent/README.zh.md#agent-names)定义名称规则。升级前创建的子级继续以 Session id 作为名称。脚本、PTC、SDK 调用方和 transcript(文本记录)需要适配以下变更: + +- `send_message({ agent_id, message })` 改为 `send_message({ agent, message })`,`interrupt_agent({ agent_id })` 改为 `interrupt_agent({ agent })`。`agent` 接受名称或 Session id。仍传入 `agent_id` 的调用无法通过参数校验。 +- `subagent`、`subagent_fork` 和 `subagent_oar` 接受可选的 `name`。它们返回 `started subagent `,结果为 `{ kind: 'activation', name }`。 +- `list_agents` 返回 `{ name, status, description? }` 行,取代含 `kind`、`id`、`label`、`parent`、`depth` 和 `reason` 的行。不可读分支以 `status` 报告 `corrupt`、`unsupported` 或 `unavailable`。文本行格式为 ` [] — `。 +- Team 任务结果以 `owner`(所有者名称)取代 `ownerId`。`team_task_list` 和 `team_task_update` 的 `owner` 接受名称。查询无所有者任务时,用 `unowned: true` 替代 `owner: "unowned"`。 +- 本地创建和父级投递的外部创建写入第 3 版 `subagent/catalog` 载荷。本版本之前的发布会拒绝包含该载荷的目录,因此无法打开升级后创建过 subagent 的 Session。参见[持久化确认记录](../../../persistence-changes/2026-11-02-subagent-catalog-names.zh.md)。 +- `SubagentActivation` 新增必需字段 `name`,`SubagentActivationSpec` 接受可选的 `name`。 + +## 迁移 + +1. 在脚本、PTC 程序和 SDK 调用中,将 `send_message` 和 `interrupt_agent` 的 `agent_id` 参数改名为 `agent`。传入 `list_agents` 或启动回执报告的名称;已保存的 Session id 仍可使用。 +2. 更新 `list_agents` 输出的解析器,读取 `name`、`status` 和 `description`。只有完整命名路径编码创建者:最后一个 `/` 之前的前缀,不含 `/` 时为 `lead`。未命名的子级或祖先导致回退到 Session id;这些行既不提供创建者,也不提供深度。 +3. 更新 Team 任务消费方,读取 `owner` 而非 `ownerId`。调用 `ctx.agentTeams` 的 host 代码仍读取 `ownerId`;展示时读取 `ownerName ?? ownerId`。新分配会持久化可选的 `ownerName`,历史记录保持不变。旧投影会拒绝带名称的任务;参见[确认记录](../../../persistence-changes/2026-11-02-team-task-owner-names.zh.md)。 +4. 从委派工具结果读取 `name` 而非 `subagentId`。更新包含 `agent_id` 参数、`started subagent ` 回执、身份运行时上下文文本、`Agent sent a message` 署名、`Background subagent ` 通知或旧 `list_agents` 行的录制 transcript 或 fixture。署名和通知现在使用名称。 +5. 构造 `SubagentActivation` 值时添加 `name`。读取目录行的代码可读取可选的 `name` 字段。 +6. 在创建 subagent 之前,升级所有打开同一 Session 存储的进程。旧版本无法打开包含第 3 版目录载荷的 Session。 +7. 验证迁移:以 `name: "reviewer"` 启动一个 subagent,确认回执为 `started subagent reviewer`,`list_agents` 列出 `reviewer`,且 `send_message({ agent: "reviewer", message })` 成功。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.i18n.yaml new file mode 100644 index 0000000000..928d1d95aa --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/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.2-rc.1/plugin-update-method/guide.md +/: + en: 24d651741581bb5e + zh: 670f4299c6fcb8b4 +/bundleinfo-update-replaces-bundleinfo-updatable: + en: 3ccbfa4a29f90906 + zh: 3ff0b0d86a50c92f +/bundleinfo-update-replaces-bundleinfo-updatable/change: + en: 23ec2f7f6b1e6197 + zh: 33d096a4901d2de7 +/bundleinfo-update-replaces-bundleinfo-updatable/migration: + en: 782c9ca1a60d80a1 + zh: 6528822070e970ee diff --git a/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.md new file mode 100644 index 0000000000..621e3da6eb --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "BundleInfo.updatable is replaced by BundleInfo.update, and ManagementError gains the unexpected-package code." +--- + +# `BundleInfo.update` replaces `BundleInfo.updatable` + +English | [中文](guide.zh.md) + +## Change + +`pluginManager.listBundles()` no longer returns `BundleInfo.updatable: boolean`. It returns the optional `BundleInfo.update`, which is `'registry'` for a bundle whose newer versions `listVersions` reads, `'git'` for a bundle installed from a Git spec pinned to a full commit, and absent for a bundle `updateBundle` cannot replace. A bundle that `updatable: true` marked before is now `update: 'registry'`. `updateBundle` also accepts a `'git'` bundle when `options.spec` names a newer commit of the same repository directory. + +`ManagementError['code']` gains `unexpected-package`: an installation run with `InstallBundleOptions.expected`, which `updateBundle` always passes, fails with it when the spec installs another package or version. + +This affects code that reads `listBundles()` through the Host API, `@deepseek-ai/dsh-plugin-manager/types`, or the remote client in `@deepseek-ai/dsh-api-remotes/client`, and code that switches exhaustively over `ManagementError['code']`. The `plugin_manager` tool never returned the field and is unchanged. + +## Migration + +1. Replace each read of `updatable`. To decide whether `listVersions` can list a bundle's versions, test `bundle.update === 'registry'`; to decide whether `updateBundle` can replace it at all, test `bundle.update !== undefined`. +2. If code switches exhaustively over `ManagementError['code']`, handle `unexpected-package`. +3. Run your type check; it succeeds when no `updatable` read and no unhandled `ManagementError` code remains. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.zh.md new file mode 100644 index 0000000000..dbac4fe064 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/plugin-update-method/guide.zh.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "BundleInfo.updatable 由 BundleInfo.update 取代,ManagementError 新增 unexpected-package 错误码。" +--- + +# `BundleInfo.update` 取代 `BundleInfo.updatable` + +[English](guide.md) | 中文 + +## 变更 + +`pluginManager.listBundles()` 不再返回 `BundleInfo.updatable: boolean`,改为返回可选的 `BundleInfo.update`:`listVersions` 能读取其较新版本的组合包为 `'registry'`,从固定到完整提交的 Git spec 安装的组合包为 `'git'`,`updateBundle` 无法替换的组合包不含此字段。此前标记为 `updatable: true` 的组合包现在为 `update: 'registry'`。当 `options.spec` 指向同一仓库目录的较新提交时,`updateBundle` 也接受 `'git'` 组合包。 + +`ManagementError['code']` 新增 `unexpected-package`:带 `InstallBundleOptions.expected` 运行的安装(`updateBundle` 总会传入该选项)在 spec 安装了其他包或其他版本时以此错误码失败。 + +受影响的代码包括:通过 Host API、`@deepseek-ai/dsh-plugin-manager/types` 或 `@deepseek-ai/dsh-api-remotes/client` 中的远端客户端读取 `listBundles()` 的代码,以及对 `ManagementError['code']` 做穷尽 switch 的代码。`plugin_manager` 工具从未返回该字段,保持不变。 + +## 迁移 + +1. 替换每一处对 `updatable` 的读取。要判断 `listVersions` 能否列出组合包的版本,检查 `bundle.update === 'registry'`;要判断 `updateBundle` 是否能以任何方式替换它,检查 `bundle.update !== undefined`。 +2. 如果代码对 `ManagementError['code']` 做穷尽 switch,需要处理 `unexpected-package`。 +3. 运行类型检查;当不再有对 `updatable` 的读取,也没有未处理的 `ManagementError` 错误码时,类型检查通过。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.i18n.yaml new file mode 100644 index 0000000000..055b116a6e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/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.2-rc.1/runtime-context-reminders/guide.md +/: + en: 9420cefcafb5ca18 + zh: 68b86eac2f0df9ca +/runtime-context-reminder-records: + en: 24314b42a810c1c9 + zh: caa0d211650dd3a8 +/runtime-context-reminder-records/change: + en: ccbbcf44f91bfdac + zh: 0ffa950b9ac802bf +/runtime-context-reminder-records/migration: + en: f1dfb64843da4574 + zh: b7d12032c4e411d8 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.md new file mode 100644 index 0000000000..d8fd27a01e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Runtime-context log text gains reminder framing, and clear markers record empty snapshot sections." +--- + +# Runtime-context reminder records + +English | [中文](guide.zh.md) + +## Change + +New `user/message` records with `source.kind: 'runtime-context'` wrap their text in ``. The first snapshot introduces the current session without asserting that it replaces earlier context; later snapshots explicitly replace context retained in conversation summaries. Clear reminders now carry `form: 'snapshot'` and `sections: []`. + +A retained legacy snapshot or clear marker without `source.sections` is refreshed once on the next admitted request. This adds a message without rewriting the existing request prefix. A legacy snapshot with unchanged recorded sections is not refreshed solely for its wording. Session format and the public `renderContextSnapshot()` and `joinContextSections()` helpers remain unchanged. + +## Migration + +1. Update consumers that match runtime-context text literally to recognize the reminder wrapper and the initial/update introductions. Prefer `source.kind` and available `source.sections` when identifying or inspecting these records. +2. Treat empty sections as cleared context, and retain support for older records without structured sections. Verify both an initial snapshot and a clear reminder in resumed-session output; existing Session files need no rewrite. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.zh.md new file mode 100644 index 0000000000..117a635837 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/runtime-context-reminders/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "运行时上下文日志文本增加提醒包装,清空标记记录空的快照条目。" +--- + +# 运行时上下文提醒记录 + +[English](guide.md) | 中文 + +## 变更 + +新写入且 `source.kind: 'runtime-context'` 的 `user/message` 记录会将文本包在 `` 内。首份快照介绍当前会话,不宣称覆盖更早的上下文;后续快照明确覆盖对话摘要保留的上下文。清空提醒现在携带 `form: 'snapshot'` 和 `sections: []`。 + +保留的旧快照或清空标记若没有 `source.sections`,会在下一次获准请求时刷新一次。这会增加一条消息,但不改写现有请求前缀。若旧快照的已记录条目未变,则不会仅因文案而刷新。Session 格式及公开的 `renderContextSnapshot()`、`joinContextSections()` 辅助方法保持不变。 + +## 迁移 + +1. 更新按字面匹配运行时上下文文本的消费方,使其识别提醒包装和首次/后续引导语。识别或检查记录时,优先使用 `source.kind` 及可用的 `source.sections`。 +2. 将空条目视为上下文已清空,并继续支持不带结构化条目的旧记录。检查恢复会话输出中的首次快照和清空提醒;现有 Session 文件无需改写。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.i18n.yaml new file mode 100644 index 0000000000..f87c9bc21e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/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.2-rc.1/web-search-billing/guide.md +/: + en: d5f047c8126e2039 + zh: 921852a96d5fdaf2 +/deepseek-web-search-and-fetch-choose-their-credential-by-billing: + en: 5b4ad8a6553f2017 + zh: 33d0dbee4bbadad8 +/deepseek-web-search-and-fetch-choose-their-credential-by-billing/change: + en: c479258a05c03e30 + zh: 9479ed43eebd03c2 +/deepseek-web-search-and-fetch-choose-their-credential-by-billing/migration: + en: f02650a1b0730ecf + zh: e6ddb011af754005 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.md new file mode 100644 index 0000000000..655a28fefc --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.md @@ -0,0 +1,41 @@ +--- +kind: upgrade-guide +description: "DeepSeek Search and Fetch add per-capability billing and replace provider authentication fields with ordered credential sources." +--- + +# DeepSeek web search and fetch choose their credential by billing + +English | [中文](guide.zh.md) + +## Change + +The DeepSeek Search and Fetch APIs (`web-deepseek-api`) previously used the signed-in account only in `deepseek-account` conversations; other conversations required an API key. New `searchBilling` and `fetchBilling` settings accept `auto` (default), `account`, or `api-key`. With `auto`, DeepSeek model routes behave as before; other conversations and operations outside a Session now use the signed-in account when no key exists. `account` uses only the account; `api-key` only the key. Non-empty `*BaseURL` or `*ApiKeyEnv` settings still take precedence. Other providers, including local HTTP fetch, are unaffected. + +In TypeScript, `DeepSeekSearchApiOptions` and `DeepSeekFetchApiOptions` lose their top-level `baseURL`. Their `auth` becomes `{ sources, missingCredentialMessage? }`. Each account or API-key source carries its own address; list order replaces the implicit account-first order. Direct provider constructors must migrate. + +With an `AbortSignal`, credential-read errors now retain the original `Error` as `cause` and its name in the diagnostic without an extra `Error:` wrapper. A resolver throwing a DOMException named `AbortError` now yields `WEB_ABORTED`, not `WEB_PROVIDER_ERROR`, even when that signal is not aborted. The no-signal path already behaves this way. + +## Migration + +1. To disable the new account fallback, set both capabilities to `api-key` on the `web-deepseek-api` row, or choose **DeepSeek API key** under **Plugins > Built-in plugin settings > Web search and fetch** when each DeepSeek API is selected: + + ```yaml + - name: '@deepseek-ai/dsh-web-deepseek-api' + config: + searchBilling: api-key + fetchBilling: api-key + ``` + + This also disables account billing in account conversations; it does not fully restore the old route-dependent behavior. +2. Migrate direct `DeepSeekSearchApiProvider` and `DeepSeekFetchApiProvider` constructors. The table describes TypeScript options, not YAML settings: + + | Old option | Replacement | + |---|---| + | `baseURL: base`, `auth.apiKey: key` | `auth.sources: [{ kind: 'api-key', baseURL: base, key }]` | + | `auth.resolveApiKey`, `auth.apiKeyEnv` | An API-key source with `key: { ref, configured, resolve }`. Keep the reference (`DEEPSEEK_API_KEY` when omitted); `configured()` checks presence without reading the value, and `resolve()` reads it. | + | `auth.account` | `{ kind: 'account', ...account }`; preserve its address and callbacks. Place it before the Key source to retain account-first behavior. | + | `auth.missingKeyMessage` | `auth.missingCredentialMessage` | + | `auth.invalidRoute` | `{ kind: 'misconfigured', message }` at the position of the invalid source; it fails only when reached. | + +3. Update error handling to recognize resolver `AbortError` as cancellation. Do not depend on the extra error wrapper or its diagnostic prefix. +4. Confirm migrated constructors type-check. With the DeepSeek APIs selected and `api-key` billing, missing keys withhold their tools; configured keys allow requests. Local HTTP fetch remains unaffected. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.zh.md new file mode 100644 index 0000000000..90c7cc7314 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/web-search-billing/guide.zh.md @@ -0,0 +1,41 @@ +--- +kind: upgrade-guide +description: "DeepSeek Search 与 Fetch 新增按能力选择的计费方式,并将提供方认证字段替换为有序凭据来源。" +--- + +# DeepSeek 网页搜索与获取按计费方式选择凭据 + +[English](guide.md) | 中文 + +## 变更 + +DeepSeek Search 与 Fetch API(`web-deepseek-api`)过去只在 `deepseek-account` 会话中使用已登录账号;其他会话需要 API 密钥。新增的 `searchBilling` 与 `fetchBilling` 设置接受 `auto`(默认)、`account` 或 `api-key`。在 `auto` 下,DeepSeek 模型路由的行为不变;其他会话与会话之外的操作在没有密钥时改用已登录账号。`account` 只使用账号,`api-key` 只使用密钥。非空的 `*BaseURL` 或 `*ApiKeyEnv` 设置仍然优先。其他提供方(包括本地 HTTP 抓取)不受影响。 + +在 TypeScript 中,`DeepSeekSearchApiOptions` 与 `DeepSeekFetchApiOptions` 移除了顶层 `baseURL`。其 `auth` 改为 `{ sources, missingCredentialMessage? }`。每个账号或 API 密钥来源携带自己的地址;列表顺序替代隐式的账号优先顺序。直接调用提供方构造函数的代码需要迁移。 + +传入 `AbortSignal` 时,凭据读取错误现在保留原始 `Error` 作为 `cause`,诊断也保留其名称,不再额外包装一层 `Error:`。解析函数抛出名为 `AbortError` 的 DOMException 时,即使该 signal 未取消,现在也返回 `WEB_ABORTED`,而非 `WEB_PROVIDER_ERROR`。未传 signal 的路径原本就采用此行为。 + +## 迁移 + +1. 要禁用新增的账号回退,在 `web-deepseek-api` 行把两个能力都设为 `api-key`;也可以在**插件 > 内置插件配置 > 网页搜索与获取**中,选中相应的 DeepSeek API 后选择 **DeepSeek API Key**: + + ```yaml + - name: '@deepseek-ai/dsh-web-deepseek-api' + config: + searchBilling: api-key + fetchBilling: api-key + ``` + + 这也会禁用账号会话中的账号计费,并不能完全恢复原来按路由选择的行为。 +2. 迁移直接调用 `DeepSeekSearchApiProvider` 与 `DeepSeekFetchApiProvider` 构造函数的代码。下表描述 TypeScript 选项,而非 YAML 设置: + + | 原选项 | 替换方式 | + |---|---| + | `baseURL: base`、`auth.apiKey: key` | `auth.sources: [{ kind: 'api-key', baseURL: base, key }]` | + | `auth.resolveApiKey`、`auth.apiKeyEnv` | API 密钥来源的 `key: { ref, configured, resolve }`。保留原引用(省略时为 `DEEPSEEK_API_KEY`);`configured()` 只检查是否存在,不读取值,`resolve()` 读取值。 | + | `auth.account` | `{ kind: 'account', ...account }`;保留其地址和回调。放在 Key 来源之前可保留账号优先行为。 | + | `auth.missingKeyMessage` | `auth.missingCredentialMessage` | + | `auth.invalidRoute` | 在无效来源的位置放置 `{ kind: 'misconfigured', message }`;只有轮到该项才失败。 | + +3. 更新错误处理,将解析函数的 `AbortError` 识别为取消。不要依赖额外的错误包装层或其诊断前缀。 +4. 确认迁移后的构造代码通过类型检查。选中 DeepSeek API 并使用 `api-key` 计费时,缺少密钥会隐藏相应工具;配置密钥后可以发请求。本地 HTTP 抓取不受影响。 diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 3bfd570184..5edeb61005 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -30,5 +30,5 @@ en: e05d4b0a901e8ef0 zh: 9516cebb3e5c3692 /llm-adapters/error-handling: - en: d54065c52caf7910 - zh: 79b3ebc22a38286d + en: 08670c0bc7b8f494 + zh: 37312af73d0acf91 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 32e412b10a..59eeb0078c 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -153,7 +153,7 @@ Compare the two shipped adapters to see the same harness contract implemented ov ## Error handling -Adapters throw transport and protocol failures as `LlmError` values with stable codes. The agent loop preserves the error and code for diagnostics and policy; it does not convert an ordinary `Error` automatically. Every provider HTTP request must also merge `attributionHeaders()` and forward `options.signal`. +Adapters throw transport and protocol failures as `LlmError` values with stable codes. `new LlmError` accepts only the built-in codes of `LLM_FAILURE_CODES`; an adapter's own code goes through `LlmError.fromFailure`. The agent loop preserves the error and code for diagnostics and policy; it does not convert an ordinary `Error` automatically. Every provider HTTP request must also merge `attributionHeaders()` and forward `options.signal`. ```ts import { @@ -180,7 +180,7 @@ class HttpAdapter extends LlmAdapter { ...options.signal ? { signal: options.signal } : {}, }) if (!response.ok) { - throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR') + throw LlmError.fromFailure({ message: `Provider API error: ${response.status}`, code: 'PROVIDER_HTTP_ERROR', status: response.status }) } // A real adapter parses the response and emits the complete chunk sequence. yield { type: 'finish', reason: { kind: 'stop' } } diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index bfa9842ce5..8ad0550881 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -153,7 +153,7 @@ ctx.llm.registerAdapter(['my-provider'], adapter) ## 错误处理 -适配器应通过带稳定 code 的 `LlmError` 抛出传输和协议故障;agent loop(智能体循环)会保留该错误及其 code,用于诊断和策略处理。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。 +适配器应通过带稳定 code 的 `LlmError` 抛出传输和协议故障。`new LlmError` 只接受 `LLM_FAILURE_CODES` 中的内置 code;适配器自己的 code 通过 `LlmError.fromFailure` 抛出。agent loop(智能体循环)会保留该错误及其 code,用于诊断和策略处理。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。 ```ts import { @@ -180,7 +180,7 @@ class HttpAdapter extends LlmAdapter { ...options.signal ? { signal: options.signal } : {}, }) if (!response.ok) { - throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR') + throw LlmError.fromFailure({ message: `Provider API error: ${response.status}`, code: 'PROVIDER_HTTP_ERROR', status: response.status }) } // A real adapter parses the response and emits the complete chunk sequence. yield { type: 'finish', reason: { kind: 'stop' } } diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index c02bbea299..9a1b29aca6 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -9,8 +9,8 @@ en: dc8c41fbf9869179 zh: e45976a585cb9c86 /web-ui-style-reference/component-rules: - en: 8b301ae4862ee8e5 - zh: eb65019fb1e26f30 + en: 2cc062ec3939d19f + zh: 87bb9246cfd2a713 /web-ui-style-reference/input-capabilities: en: bf2e98bce68743e2 zh: fc58dcbb60bb944a diff --git a/docs/web-styling.md b/docs/web-styling.md index 1b8a631f79..30578c3378 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -15,7 +15,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Reuse the control before restyling one: the [ui-primitives component catalog](../packages/client/ui-primitives/README.md#component-catalog) is the only channel that crosses feature packages, and a deliberate visual difference belongs in a prop there rather than in a second copy ([reference](../packages/client/ui-primitives/README.md)). - Use CSS Modules and `clsx`; do not add a component library or Tailwind. -- Use `--dsw-alias-*` semantic tokens in feature components. Do not copy static palette values or write literal colors there. +- Use `--dsw-alias-*` semantic tokens in feature components. Do not copy static palette values or write literal colors there. The ui-theme color-token spec rejects static palette references and hex, color-function, `white`, and `black` literals in feature stylesheets and static palette references in feature scripts; `box-shadow` values, mask properties, alpha-0 literals, and its listed artwork and pre-theme files are exempt. - Keep theme selectors out of feature component CSS. Light/dark overrides belong to the theme owner. - Pair font sizes with line heights and use the theme typography variables when an existing role matches. - Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors. diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 4c56e73820..18311558d4 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -15,7 +15,7 @@ - 重新设计控件样式之前先复用控件:[ui-primitives 组件目录](../packages/client/ui-primitives/README.zh.md#component-catalog)是唯一跨功能包的通道,有意的视觉差异应作为那里的一个 prop,而不是另起一份拷贝([参考](../packages/client/ui-primitives/README.zh.md))。 - 使用 CSS Modules 和 `clsx`;不得添加组件库或 Tailwind。 -- 功能组件使用 `--dsw-alias-*` 语义 token。不得复制静态色板值或在其中写入颜色字面量。 +- 功能组件使用 `--dsw-alias-*` 语义 token。不得复制静态色板值或在其中写入颜色字面量。ui-theme 的 color-token spec 拒绝功能样式表中的静态色板引用与十六进制、颜色函数、`white` 和 `black` 字面量,以及功能脚本中的静态色板引用;`box-shadow` 值、遮罩属性、alpha 为 0 的字面量,以及其列出的插图和主题加载前文件不受此限制。 - 功能组件 CSS 不得包含主题选择器。明暗主题覆盖属于主题所有方。 - 字体大小必须与行高配对;已有角色匹配时使用主题排版变量。 - 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。 diff --git a/package.json b/package.json index dcf0f5a609..55ff3a62a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "license": "MIT", "private": true, "type": "module", @@ -263,7 +263,7 @@ "@yarnpkg/cli-dist": "4.18.0", "eslint-plugin-sonarjs": "^4.2.0", "execa": "^10.0.1", - "zod": "^4.4.3", + "zod": "4.6.6", "extract-zip": "^2.0.1", "fast-check": "^4.9.0", "fflate": "^0.8.2", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index d96e805d23..686f5672c1 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -15,8 +15,8 @@ en: ffc55792d6e922e1 zh: 83bd759638857e2d /packages/package-groups: - en: 60a6056460b8d4c1 - zh: fce319504ae9b6ef + en: 704e948001e25580 + zh: dcfbb1b483d2a95a /packages/release-expectations: en: 236081c1659b25ab zh: 809110adf003802e diff --git a/packages/README.md b/packages/README.md index 85a63643d0..431eadfc37 100644 --- a/packages/README.md +++ b/packages/README.md @@ -57,7 +57,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`experimental/`](experimental/README.md) | Capabilities under evaluation; [status and support policy](experimental/README.md#status) | | [`workflow/`](workflow/README.md) | Workflow seam, PTC process engine, and the model-facing `workflow` tool | | [`web/`](web/README.md) | Web capability family: seam, search/fetch providers, model-facing web tools | -| [`document/`](document/README.md) | Shared Host Office-to-PDF conversion | +| [`document/`](document/README.md) | Shared Host Office rendering, conversion, and recalculation, the Office tools, and Kit engine resolution | | [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index f293666d2f..5855d9c3fd 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -57,7 +57,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`experimental/`](experimental/README.zh.md) | 仍在评估中的能力;[状态与支持策略](experimental/README.zh.md#status) | | [`workflow/`](workflow/README.zh.md) | 工作流 seam、PTC 进程引擎、面向模型的 `workflow` 工具 | | [`web/`](web/README.zh.md) | Web 能力系列:seam、搜索/获取提供方、面向模型的 Web 工具 | -| [`document/`](document/README.zh.md) | 共享宿主 Office 到 PDF 转换 | +| [`document/`](document/README.zh.md) | 共享宿主 Office 渲染、转换与重算、Office 工具与 Kit 引擎解析 | | [`attachment/`](attachment/README.zh.md) | 持久附件标识、校验、本地内容寻址存储 | | [`spill/`](spill/README.zh.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | | [`todo/`](todo/README.zh.md) | 面向模型的 `todo_write` 工具 | diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 1ffb46e425..9a54c5dd58 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index 6970251ca0..7731b7150d 100644 --- a/packages/api/account-controller/package.json +++ b/packages/api/account-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-account-controller", "description": "Expose safe account operations over authenticated Remote", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -39,14 +39,18 @@ "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 5a3c973df3..99c310776c 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -56,7 +56,10 @@ "lib/client.browser.js", "lib/client.node.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { @@ -80,6 +83,6 @@ "@deepseek-ai/dsh-util-crypto": "workspace:*", "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" } } diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index a948e43c3c..cfd09bcfb5 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -60,16 +60,20 @@ "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json index a240048ef8..5d9c311210 100644 --- a/packages/api/remote-access/package.json +++ b/packages/api/remote-access/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remote-access", "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -60,15 +60,19 @@ "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 529a762528..bf7dbbfa08 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -49,7 +49,10 @@ "lib/index.js", "lib/client.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "dependencies": { "@deepseek-ai/dsh-deque": "workspace:*", @@ -89,14 +92,14 @@ "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "zod": "4.4.3", + "zod": "4.6.6", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", "@deepseek-ai/dsh-mcp-manager": "workspace:*", "@deepseek-ai/dsh-plugin-market": "workspace:*", "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", - "@deepseek-ai/dsh-office-to-pdf": "workspace:*", + "@deepseek-ai/dsh-office-kit": "workspace:*", "@deepseek-ai/dsh-api-account-controller": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index a3012bb69f..88a41e3830 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -10,7 +10,7 @@ import agentTeamsRemote from '@deepseek-ai/dsh-agent-team/remote' export type {} from '@deepseek-ai/dsh-agent-team/remote' import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' -import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote' +import officeKitRemote from '@deepseek-ai/dsh-office-kit/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import scheduleRemote from '@deepseek-ai/dsh-schedule/remote' import llmRemote from '@deepseek-ai/dsh-llm/remote' @@ -63,7 +63,7 @@ export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-api-account-controller/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-schedule/remote' -export type {} from '@deepseek-ai/dsh-office-to-pdf/remote' +export type {} from '@deepseek-ai/dsh-office-kit/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' @@ -204,7 +204,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, - officeToPdfRemote, userQuestionsRemote, webRemote, + officeKitRemote, userQuestionsRemote, webRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 858f7b7d7d..165661a57e 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -110,7 +110,7 @@ "path": "../../boot/plugin-market" }, { - "path": "../../document/office-to-pdf" + "path": "../../document/office-kit" }, { "path": "../account-controller" diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 38ebd48e01..3d31867b67 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: 300f02be359a3170 - zh: ac7fb4abbe78eddf + en: 9cac36e3c0a32959 + zh: 37c42cc3edafa798 /session-controller/client-references: en: e8979a803ec3e194 zh: c567efcd971728ba @@ -24,8 +24,8 @@ en: 954923654eebe2dc zh: 6bd9a7d2ea965aed /session-controller/configuration: - en: c92590fbaf75e45e - zh: fcac67017bb0e3ab + en: 8f62f7183c67fc02 + zh: 0e2db400b376629f /session-controller/model-experience: en: 30b917bf822bb4df zh: b0b023b5b526c317 @@ -33,8 +33,8 @@ en: 22fc0c9dbedd3bb6 zh: fad040ca55fd0e0a /session-controller/known-limitations-and-deferred-work: - en: 5db0f9f69a8be926 - zh: 6ba1cc4a3850fcbc + en: 23bc3db43e84daa6 + zh: 0242338ac7bd24fa /session-controller/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 960c9bde1f..b430594b0e 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 +Prompt retries use the request identity in the first committed inbox insertion, including after the message is claimed or removed. Admission checks again after asynchronous attachment preparation, so concurrent retries accept one input. Acceptance acknowledges insertion, not execution or completion. + 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. @@ -64,7 +66,7 @@ During uninterrupted following, transcript and steering echoes stay in Chat thro The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent. -Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn. +Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation; an increment skips numbers that other Host-listed titles already use. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn. A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`. @@ -110,6 +112,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Blockquotes travel as ordinary prompt text without quote metadata. Local submission echoes and durable messages use the same text rendering rules; queue edits replace content while retaining prompt correlation. +An empty `prompt.content` array requests a new turn only for an eligible failed, idle ordinary Session in queue mode. Pending human input prevents admission; non-waking injected context is retained. `conversationContinuation` supplies the shared six-code manual policy, independently of provider retry configuration. The input follows ordinary inbox and Loop admission and records an empty `user/message` with `source.kind: 'retry'`; its input identity and caller metadata support deduplication and recovery. Whitespace-only text parts remain invalid. + ## Model Experience @@ -127,6 +131,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - The image byte cap does not validate decoded dimensions or pixel count. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. +- Fork title increments compare candidates with the Client list before the Host normalizes them: concurrent forks of one source, or a title the Host truncates at its byte limit, can still produce duplicate titles. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. - Accepted/running display memory lives only in Client memory and is lost on page reload. - That memory is not shared between tabs: one tab can show a converted row while another still shows `New Session` for the same Session. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 5b18b4aa38..314ec58840 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 数据。 @@ -64,7 +66,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。 -Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。 +Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话;递增时会跳过其他由 Host 列出的标题已使用的编号。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。 恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。 @@ -110,6 +112,8 @@ GUI 模型选择要求确切提供方/模型对出现在可用目录中;不 Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地提交回显与持久消息使用同一文本渲染规则;队列编辑替换 content,同时保留 prompt 关联信息。 +空的 `prompt.content` 数组仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时请求新轮次。待消费的用户输入阻止准入,非唤醒的注入上下文保留。`conversationContinuation` 提供共用的六码手动策略,独立于提供方重试配置。输入遵循普通 inbox 和 Loop 准入,记录带 `source.kind: 'retry'` 的空 `user/message`;输入身份与调用方元数据用于去重和恢复。仅含空白的文本块仍无效。 + ## 模型体验 @@ -127,6 +131,7 @@ Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地 - 图片字节上限不校验解码后的尺寸或像素数。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。 +- Fork 标题递增会在 Host 规范化候选标题之前,先将其与客户端列表比较:同一来源的并发 Fork,或被 Host 按字节上限截断的标题,仍可能产生重复标题。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 - 受理/运行的展示记忆只存在于客户端内存,页面重载后即丢失。 - 该记忆不在 tab 之间共享:同一会话可能在一个 tab 中已转正,在另一个 tab 中仍显示为 `New Session`。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 30e143744e..4f29e46eac 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -64,10 +64,14 @@ "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { @@ -76,7 +80,7 @@ "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "mime-types": "^3.0.2", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index a52d2c155a..c1fede0937 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -128,7 +128,8 @@ export interface ISessions { * seq (a real event seq the caller already knows; a cut inside an open * turn is balanced Host-side with synthetic closers, and omission selects * the latest completed-turn prefix), and whether to increment an - * inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename. + * inherited durable title, skipping numbers Host-listed titles already use, + * before resolving. `onCreated` observes the catalogued child before that optional rename. * @returns the child session id. * @throws when the fork fails, or when a requested child-title rename fails after creation. */ diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 4b70888468..86b1ad4757 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -130,12 +130,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id: } /** - * Increment a trailing fork number while preserving its half-width or - * full-width parentheses; an unnumbered title starts with ` (1)`. + * Increment the fork number until the candidate is not taken. * @param title - source session's durable title. + * @param taken - durable titles of Host-listed sessions. * @returns the title assigned to the fork child. */ -function increasedForkTitle(title: string): string { +function increasedForkTitle(title: string, taken: ReadonlySet): string { + let next = incrementedTitle(title) + while (taken.has(next)) next = incrementedTitle(next) + return next +} + +/** + * Increment a trailing fork number while preserving its half-width or + * full-width parentheses; an unnumbered title starts with ` (1)`. + * @param title - title to increment. + * @returns the title with the next fork number. + */ +function incrementedTitle(title: string): string { const ascii = /^(.*?)\((\d+)\)$/u.exec(title) if (ascii?.[1] !== undefined && ascii[2] !== undefined) { return `${ascii[1]}(${BigInt(ascii[2]) + 1n})` @@ -455,7 +467,8 @@ export class ClientSessions implements ISessions { * seq (a real event seq the caller already knows; a cut inside an open * turn is balanced Host-side with synthetic closers, and omission selects * the latest completed-turn prefix), and whether to increment an - * inherited durable title before resolving, plus permission to start source migration. + * inherited durable title, skipping numbers Host-listed titles already use, + * before resolving, plus permission to start source migration. * @returns the child session id. * @throws {SessionForkError} with the source id. * @throws {Error} when a requested child-title rename fails after creation. @@ -480,7 +493,9 @@ export class ClientSessions implements ISessions { const childId = result.value.sessionId opts.onCreated?.(childId) if (sourceTitle !== undefined) { - const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle)) + const list = this.list.getSnapshot() + const taken = new Set(list.ids.flatMap(id => list.byId[id]?.title ?? [])) + const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle, taken)) if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`) } return childId diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index d306953c8c..921d71cd23 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -1,5 +1,8 @@ /** Session commands whose activation policy is explicit at each Remote method. */ +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { continuationProjectionDefinition, type ContinuationState } from './continuation.ts' + import { modelAvailable } from './catalog.ts' import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' @@ -14,10 +17,10 @@ import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' -import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import { buildForkSeed } from '@deepseek-ai/dsh-session/fork' import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import { assertNever } from '@deepseek-ai/dsh-util-values' @@ -59,7 +62,7 @@ type PromptContentCandidate = /** * Whether prompt content carries non-whitespace text or an attachment. * @param content - submitted or edited prompt parts. - * @returns false for content the Agent must not receive. + * @returns whether any part supplies ordinary prompt content. */ export function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { return content.some(part => part.type !== 'text' || part.text.trim().length > 0) @@ -83,6 +86,20 @@ function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): Session /** Implements Session business commands delegated by the Session Controller Remote service. */ export class SessionCommandController { + /** First-insertion facts cached per exact Session; the log remains the recovery source. */ + private readonly promptReceipts = new WeakMap + requests: Set + }>() + + private continuation!: ProjectionHandle + + /** Register complete-history empty-prompt eligibility. @returns when command admission is ready. */ + async initialize(): Promise { + this.continuation = await this.ctx.sessionProjections.register(continuationProjectionDefinition) + } + /** * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. * @param agents - sole owner of create, resume, and Agent composition. @@ -298,12 +315,28 @@ export class SessionCommandController { * Admit one prompt after Agent and attachment validation; the Remote method * has already validated the content and built the durable message source. * @param request - Session identity, prompt content, and delivery mode. - * @param source - the `user-rpc` source recorded on the accepted message. + * @param source - caller metadata recorded on the accepted ordinary or retry user message. * @returns acknowledgement that the Agent accepted the prompt. */ async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) - if (hasPromptRequest(agent, request.requestId)) return { accepted: true } + if (this.hasPromptRequest(agent.session, request.requestId)) return { accepted: true } + if (request.content.length === 0) { + try { + return await agent.runMaintenance(() => { + const inbox = agent.inbox + if (request.mode !== 'queue' || this.continuation.stateOf(agent.session).ending === null + || inbox.nextTurn.length > 0 || inbox.nextStep.some(message => message.source.kind === 'user')) { + throw new RemoteError('session/agent-busy', 'generation continuation is unavailable', { reason: 'NO_CONTINUABLE_FAILURE_OR_PENDING_INPUT' }) + } + agent.followup(createUserMessage({ content: [], source })) + return Promise.resolve({ accepted: true as const }) + }) + } catch (error: unknown) { + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError('session/agent-busy', 'generation continuation was not accepted', { reason: String(error) }) + } + } const admission = resolvePromptFileReceipts( request.content, receiptId => this.ctx.fileUploads.resolve(agent, receiptId), @@ -312,6 +345,7 @@ export class SessionCommandController { const hasImage = admission.content.some(part => part.type === 'image') const admit = async (): Promise => { try { + if (this.hasPromptRequest(agent.session, request.requestId)) return { accepted: true } if (hasImage) { const current = agent.model.current() if (current !== undefined) { @@ -326,6 +360,7 @@ export class SessionCommandController { } } const content = await this.ctx.attachments.admitPromptContent(admission.content) + if (this.hasPromptRequest(agent.session, request.requestId)) return { accepted: true } const message: UserMessage = createUserMessage({ content, source }) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( @@ -339,6 +374,7 @@ export class SessionCommandController { else agent.followup(message) binding.commit() } catch (error) { + if (this.hasPromptRequest(agent.session, request.requestId)) return { accepted: true } if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) @@ -350,6 +386,32 @@ export class SessionCommandController { return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } + /** Recover first input acceptance, including claimed or removed messages, from unseen log entries. */ + private hasPromptRequest(session: Session, requestId: SessionRequestId): boolean { + let receipts = this.promptReceipts.get(session) + if (receipts === undefined) { + receipts = { offset: SessionLogOffset(0), messages: new Set(), requests: new Set() } + this.promptReceipts.set(session, receipts) + } + if (receipts.offset !== session.seq) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing prompt retry history read, retained for acceptance recovery. + for (const event of session.snapshotEvents(receipts.offset)) { + if (event.type !== 'agent/inbox/spliced' && event.type !== 'user/message') continue + const messages = event.type === 'agent/inbox/spliced' ? event.data.inserted : [event.data] + for (const message of messages) { + if (receipts.messages.has(message.id)) continue + receipts.messages.add(message.id) + // A copied input is not a new request in the child, even if later reinserted there. + if (event.seq < session.inheritedEventCount) continue + const source = message.source + if ((source.kind === 'user' || source.kind === 'retry') && 'rpcId' in source) receipts.requests.add(source.rpcId) + } + } + receipts.offset = session.seq + } + return receipts.requests.has(requestId) + } + private async requireModel(selection: Pick): Promise { if (!await modelAvailable(this.ctx, selection)) { throw new RemoteError('session/model-unavailable', 'Select an available model before sending a message.', @@ -538,17 +600,3 @@ function resolvePromptFileReceipts( }) return { content: resolved, receiptIds: [...receiptIds] } } - -function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { - const matches = (message: UserMessage): boolean => { - const source = message.source - return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId - } - if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - return agent.session.snapshotEvents().some((event) => { - if (event.type !== 'user/message') return false - const source = event.data.source - return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId - }) -} diff --git a/packages/api/session-controller/src/continuation.ts b/packages/api/session-controller/src/continuation.ts new file mode 100644 index 0000000000..3be2a2be1d --- /dev/null +++ b/packages/api/session-controller/src/continuation.ts @@ -0,0 +1,58 @@ +/** Constant-size continuation eligibility over the complete durable Session. */ +import type {} from './types.ts' +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 } from 'zod' + +// Explicit manual requests use a fixed product policy, independent of automatic retry configuration. +const CONTINUABLE_FAILURES: ReadonlySet = new Set([ + 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED', +]) + +const endingSchema = z.number().int().nonnegative().transform(brandNumber).nullable() +const stateSchema = z.object({ + admitted: z.boolean(), + failedAttempt: z.boolean(), + ending: endingSchema, +}) + +/** Recoverable manual-continuation facts over admitted user input and empty retry messages. */ +export type ContinuationState = z.infer + +const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null } + +/** Eligibility requires admitted human work and a failed settlement in the last request step. */ +export const continuationProjectionDefinition = { + key: 'conversationContinuation', + stateVersion: 4, + stateSchema, + init: (): ContinuationState => EMPTY, + apply: (state, event) => { + switch (event.type) { + case 'turn/start': + return EMPTY + case 'step/start': + case 'assistant/message': + return state.failedAttempt ? { ...state, failedAttempt: false } : state + case 'user/message': + return !state.admitted && event.surfaceOp === 'append' + && ((event.data.source.kind === 'user' && event.data.content.length > 0) + || (event.data.source.kind === 'retry' && event.data.content.length === 0)) + ? { ...state, admitted: true } : state + case 'assistant/attempt': + return state.failedAttempt ? state : { ...state, failedAttempt: true } + case 'turn/end': { + const ending = event.data.reason.kind === 'error' && CONTINUABLE_FAILURES.has(event.data.reason.error.code) + && state.admitted && state.failedAttempt ? event.seq : null + return ending === null ? EMPTY : { ...EMPTY, ending } + } + default: + return state + } + }, + wire: { + viewSchema: endingSchema, + view: (state: ContinuationState) => state.ending, + }, +} satisfies ProjectionDefinition<'conversationContinuation', ContinuationState> diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 8e81986f0b..6303340cd8 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -209,7 +209,10 @@ export class SessionController extends TypertRemoteService { this.stateController = new StateController(ctx, this.historyController) this.webController = new WebController(ctx, this.historyController, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx, this.listWorkSliceMs) + await this.commands.initialize() + ctx.fiber.assertActive() await this.listState.initialize() + ctx.fiber.assertActive() ctx.plugin(SessionFileReferences) ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) @@ -464,7 +467,7 @@ export class SessionController extends TypertRemoteService { @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - if (!hasPromptContent(request.content)) { + if (request.content.length > 0 && !hasPromptContent(request.content)) { throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {}) } const clientTimeZone = request.clientTimeZone === undefined @@ -480,7 +483,7 @@ export class SessionController extends TypertRemoteService { // The caller is recorded only when it is not the owner, so owner messages keep their exact source. const principal = principalOf(this.ctx.invocation) const source: MessageSource = { - kind: 'user', + kind: request.content.length === 0 ? 'retry' : 'user', rpcId: request.requestId, ...(clientTimeZone === undefined ? {} : { clientTimeZone }), ...(principal === undefined ? {} : { principal }), diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 1d846d4c9a..49cd7c0f9d 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -8,7 +8,7 @@ import type { import type { Branded } from '@deepseek-ai/dsh-brand' import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { LlmModelBadge, TextBlock } from '@deepseek-ai/dsh-llm' -import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap, SessionProjectionSummaryExcluded, SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import type { Principal } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -16,6 +16,8 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { + /** Eligible latest failed turn/end for an empty prompt, or null. */ + conversationContinuation: SessionSeq | null /** Persisted facts used to summarize a Session without activating it. */ sessionListMetadata: SessionListMetadata /** @@ -320,11 +322,11 @@ export interface SessionForkValue { /** Session prompt request. */ export interface SessionPromptRequest { - /** Client-minted identity persisted on the exact accepted user message. */ + /** Client-minted identity persisted on the accepted inbox input, including empty continuation. */ readonly requestId: SessionRequestId readonly sessionId: SessionId readonly mode: 'queue' | 'steer' - /** At least one non-whitespace text part or attachment. */ + /** Non-whitespace text or attachments; an empty array continues an eligible failed idle Session. */ readonly content: readonly PromptContentPart[] readonly clientTimeZone?: string } @@ -396,6 +398,8 @@ declare module '@deepseek-ai/dsh-llm' { * caller other than the owner, so an absent `principal` means the owner. */ 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal } + /** Empty retry user message with browser prompt correlation and caller metadata. */ + 'retry-rpc': { kind: 'retry'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal } } } 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 0f6aff10f2..89d4e35b5b 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 @@ -3,7 +3,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Context, FiberState } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, InboxTarget } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, @@ -11,7 +11,7 @@ import type { } from '@deepseek-ai/dsh-attachment' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session' import CommandRuntime from '@deepseek-ai/dsh-commands' import { createScope } from '@deepseek-ai/dsh-scope' import FileUploads from '@deepseek-ai/dsh-client-file-upload' @@ -43,7 +43,11 @@ async function* chunks(...parts: readonly number[][]): AsyncIterable for (const part of parts) yield Uint8Array.from(part) } -async function uploadHarness(origin?: 'subagent', imageLimits: ImageAttachmentLimits = IMAGE_LIMITS): Promise<{ +async function uploadHarness( + origin?: 'subagent', + imageLimits: ImageAttachmentLimits = IMAGE_LIMITS, + seed?: readonly SessionEvent[], +): Promise<{ ctx: Context controller: SessionCommandController uploads: FileUploads @@ -63,10 +67,16 @@ async function uploadHarness(origin?: 'subagent', imageLimits: ImageAttachmentLi await ctx.plugin(AgentRegistry) await ctx.plugin(CommandRuntime) const session = ctx.sessions.create(SESSION, { - meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) }, + ...(seed === undefined ? {} : { seed, inheritedEventCount: SessionLogOffset(seed.length) }), + meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }), ...(seed === undefined ? {} : { isSeeded: true }) }, }) const inbox = createInboxStub() - const followup = vi.fn() + const accept = (target: InboxTarget, message: UserMessage): void => { + const pending = target === 'next-turn' ? inbox.nextTurn : inbox.nextStep + session.append('agent/inbox/spliced', { target, start: pending.length, inserted: [message] }) + inbox.append(target, message) + } + const followup = vi.fn((message: UserMessage) => { accept('next-turn', message) }) const options = { provider: 'fixture', model: 'fixture-model' } const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition) const agent = { @@ -81,7 +91,7 @@ async function uploadHarness(origin?: 'subagent', imageLimits: ImageAttachmentLi inbox, status: 'idle', ctx, - steer: vi.fn(), + steer: vi.fn((message: UserMessage) => { accept('next-step', message) }), followup, cancel: vi.fn(), } satisfies Agent @@ -524,14 +534,117 @@ describe('Session file uploads', () => { }) it('deduplicates a retried rpcId already present in the Agent inbox', async () => { - const { controller, agent, followup } = await uploadHarness() + const { controller, followup } = await uploadHarness() const request = promptRequest([{ type: 'text', text: 'once' }]) await controller.prompt(request, USER_SOURCE) - agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).toHaveBeenCalledOnce() }) + it.each(['claimed', 'removed'] as const)('deduplicates a %s input before user/message is logged', async (state) => { + const { controller, uploads, agent, followup } = await uploadHarness() + const file = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + const request = promptRequest([{ type: 'file', receiptId: file.receiptId }]) + await controller.prompt(request, USER_SOURCE) + if (state === 'removed') { + await controller.updateQueue({ sessionId: agent.id, itemId: agent.inbox.nextTurn[0]!.id, action: { kind: 'remove' } }) + expect(uploads.resolve(agent, file.receiptId)).toBeUndefined() + } else { + agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + agent.inbox.clear() + } + expect(agent.inbox.nextTurn).toEqual([]) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) + expect(followup).toHaveBeenCalledOnce() + }) + + it('does not reuse inherited acceptance when a copied message is reinserted in the child', async () => { + const ancestor = Session.create(SessionId('ancestor')) + const message = createUserMessage({ content: [{ type: 'text', text: 'parent input' }], source: USER_SOURCE }) + ancestor.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message] }) + ancestor.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + const { controller, agent, followup } = await uploadHarness(undefined, IMAGE_LIMITS, ancestor.snapshotEvents()) + agent.session.append('agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [message] }) + const request = promptRequest([{ type: 'text', text: 'child input' }]) + await controller.prompt(request, USER_SOURCE) + await controller.prompt(request, USER_SOURCE) + expect(followup).toHaveBeenCalledOnce() + expect(agent.inbox.nextTurn[0]?.id).not.toBe(message.id) + }) + + it('recovers acceptance from an insertion already removed before the first retry lookup', async () => { + const { controller, agent, followup } = await uploadHarness() + const message = createUserMessage({ content: [{ type: 'text', text: 'accepted' }], source: USER_SOURCE }) + agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message] }) + agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }) + await expect(controller.prompt(promptRequest([{ type: 'text', text: 'retry' }]), USER_SOURCE)).resolves.toEqual({ accepted: true }) + expect(followup).not.toHaveBeenCalled() + }) + + it.each(['queue', 'steer'] as const)('accepts concurrent %s retries only once after asynchronous preparation', async (mode) => { + const { ctx, controller, agent, followup } = await uploadHarness() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let calls = 0 + const admit = ctx.attachments.admitPromptContent.bind(ctx.attachments) + vi.spyOn(ctx.attachments, 'admitPromptContent').mockImplementation(async (content) => { + if (++calls === 2) entered.resolve(undefined) + await release.promise + return admit(content) + }) + const request = { ...promptRequest([{ type: 'text', text: 'once' }]), mode } + const prompting = Promise.all([controller.prompt(request, USER_SOURCE), controller.prompt(request, USER_SOURCE)]) + onTestFinished(async () => { + release.resolve(undefined) + await Promise.allSettled([prompting]) + }) + await entered.promise + release.resolve(undefined) + await expect(prompting).resolves.toEqual([{ accepted: true }, { accepted: true }]) + expect(followup).toHaveBeenCalledTimes(mode === 'queue' ? 1 : 0) + expect(agent.inbox.nextTurn).toHaveLength(mode === 'queue' ? 1 : 0) + expect(agent.inbox.nextStep).toHaveLength(mode === 'steer' ? 1 : 0) + }) + + it('skips image preparation for a serialized retry already accepted', async () => { + const { controller, followup, saveImages } = await uploadHarness() + const release = Promise.withResolvers() + saveImages.mockReturnValueOnce(release.promise) + const request = promptRequest([{ type: 'image', mediaType: 'image/png', data: 'AAAA' }]) + const prompting = Promise.all([controller.prompt(request, USER_SOURCE), controller.prompt(request, USER_SOURCE)]) + onTestFinished(async () => { + release.resolve([STAGED_IMAGE]) + await Promise.allSettled([prompting]) + }) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + release.resolve([STAGED_IMAGE]) + await expect(prompting).resolves.toEqual([{ accepted: true }, { accepted: true }]) + expect(saveImages).toHaveBeenCalledOnce() + expect(followup).toHaveBeenCalledOnce() + }) + + it('acknowledges acceptance when concurrent preparation fails after another call accepts', async () => { + const { ctx, controller, followup } = await uploadHarness() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(ctx.attachments, 'admitPromptContent').mockImplementationOnce(async () => { + entered.resolve(undefined) + await release.promise + throw new Error('preparation failed') + }) + const request = promptRequest([{ type: 'text', text: 'once' }]) + const prompting = controller.prompt(request, USER_SOURCE) + onTestFinished(async () => { + release.resolve(undefined) + await Promise.allSettled([prompting]) + }) + await entered.promise + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) + release.resolve(undefined) + await expect(prompting).resolves.toEqual({ accepted: true }) + expect(followup).toHaveBeenCalledOnce() + }) + it('deduplicates a retried rpcId already present in the durable log', async () => { const { controller, agent, followup } = await uploadHarness() const request = promptRequest([{ type: 'text', text: 'once' }]) diff --git a/packages/api/session-controller/tests/continuation.host.spec.ts b/packages/api/session-controller/tests/continuation.host.spec.ts new file mode 100644 index 0000000000..e7d339761d --- /dev/null +++ b/packages/api/session-controller/tests/continuation.host.spec.ts @@ -0,0 +1,162 @@ +/** Complete-log continuation projection, including recovery and publication. */ +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SessionId, SessionSeq, SessionLogOffset } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session/types' +import { expect, it, onTestFinished, vi } from 'vitest' + +import { continuationProjectionDefinition as definition } from '../src/continuation.ts' +import { paginate } from '../src/history.ts' +import { continuationTurn as turn } from '../../../client/ui-conversation/tests/continuation-fixture.ts' + +const failed = (code: string): TurnEndReason => ({ kind: 'error', error: { code, message: code } }) +function fold(events: readonly SessionEvent[]) { + return events.reduce((state, event) => definition.apply(state, event), definition.init()) +} +const events = (reason: TurnEndReason) => turn(reason).map(entry => entry.event) + +it.each(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED'])( + 'offers a manual new turn after %s independently of automatic retries', (code) => { + expect(definition.wire.view(fold(events(failed(code))))).toBe(5) + }, +) +it.each(['AUTH', 'QUOTA', 'ACCOUNT_QUOTA', 'MISSING_CREDENTIAL', 'INVALID_CREDENTIAL', 'UNKNOWN_MODEL', + 'INVALID_REQUEST', 'CONTEXT_WINDOW_EXCEEDED', 'TOOL_ACTIVATION_FAILED', 'MALFORMED_RESPONSE', 'PI_AI_ERROR', 'UNKNOWN'])( + 'leaves %s to its own recovery action', (code) => { + expect(definition.wire.view(fold(events(failed(code))))).toBeNull() + }, +) +it.each([ + { kind: 'completed' }, { kind: 'max-tokens' }, { kind: 'blocked' }, + { kind: 'interrupted' }, { kind: 'forked' }, { kind: 'aborted', reason: { kind: 'user' } }, +])('leaves a $kind turn unchanged', (reason) => { + expect(definition.wire.view(fold(events(reason)))).toBeNull() +}) + +it('requires admitted human input and a failed settlement in the last step', () => { + const base = events(failed('TIMEOUT')) + expect(fold(base.filter(event => event.type !== 'user/message')).ending).toBeNull() + expect(fold(base.filter(event => event.type !== 'assistant/attempt')).ending).toBeNull() + const user = base.find(event => event.type === 'user/message')! + const replacement: SessionEvent<'user/message'> = { ...user, + surfaceOp: { op: 'replace', startSeq: SessionSeq(0), endSeq: SessionSeq(1) }, + } + expect(fold(base.map(event => event === user ? replacement : event)).ending).toBeNull() + const injected: SessionEvent<'user/message'> = { ...user, data: createUserMessage({ + content: [{ type: 'text', text: 'mode context' }], source: { kind: 'user-approval' }, + }) } + expect(fold(base.map(event => event === user ? injected : event)).ending).toBeNull() + const prefix = base.slice(0, -1) + const step: SessionEvent<'step/start'> = { type: 'step/start', seq: SessionSeq(5), time: 0, data: { turn: 1, step: 2 } } + expect(fold([...prefix, step, base.at(-1)!]).ending).toBeNull() + const success: SessionEvent<'assistant/message'> = { type: 'assistant/message', seq: SessionSeq(5), time: 0, + surfaceOp: 'append', data: { turn: 1, step: 1, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: 'done' }], source: { provider: 'test', model: 'test' }, + }) }, + } + expect(fold([...prefix, success, base.at(-1)!]).ending).toBeNull() +}) + +it('keeps unrelated events and repeated settlements reference-stable', () => { + const base = events(failed('SERVER')) + const state = fold(base.slice(0, -1)) + expect(definition.apply(state, base[2]!)).toBe(state) + expect(definition.apply(state, base[3]!)).toBe(state) + expect(definition.apply(state, base[4]!)).toBe(state) + expect(definition.apply(definition.init(), base[1]!)).toBe(definition.init()) + const reset = definition.apply(state, base[1]!) + expect(reset.failedAttempt).toBe(false) +}) + +it('restores a checkpoint plus suffix identically to full replay and retires on a new turn', () => { + const base = events(failed('SERVER')) + const prefix = fold(base.slice(0, 3)) + const restored = definition.stateSchema.parse(JSON.parse(JSON.stringify(prefix))) + expect(base.slice(3).reduce((state, event) => definition.apply(state, event), restored)).toEqual(fold(base)) + const ended = fold(base) + expect(definition.apply(ended, { type: 'turn/start', seq: SessionSeq(6), time: 1, data: { turn: 2 } }).ending).toBeNull() + expect(definition.wire.viewSchema.parse(ended.ending)).toBe(5) + expect(() => definition.stateSchema.parse({ ...ended, ending: -1 })).toThrow() + expect(() => definition.wire.viewSchema.parse('5')).toThrow() +}) + +it('publishes a complete-log result independently of the visible page and releases registration', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const fiber = await ctx.sessionProjections.register(definition) + const session = ctx.sessions.create(SessionId('continued')) + expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull() + const history: SessionEvent[] = [] + ctx.on('session/event', (_session, event) => { history.push(event) }) + const changed = vi.fn[0]>() + ctx.sessionProjections.onChanged(changed) + session.append('turn/start', { turn: 1 }) + expect(changed).not.toHaveBeenCalled() + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'task' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + // A long failed turn exceeds the browser's 500-message page budget. + for (let index = 0; index < 600; index++) { + session.append('assistant/message', { turn: 1, step: index + 1, stream: [], message: createAssistantMessage({ content: [{ type: 'text', text: 'progress' }], source: { provider: 'test', model: 'test' } }) }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: index + 1 }) + session.append('step/start', { turn: 1, step: index + 2 }) + } + session.append('assistant/attempt', { turn: 1, step: 601, stream: [] }) + const ending = session.append('turn/end', { turn: 1, reason: failed('SERVER') }).seq + const page = paginate(history, undefined, 500, ending, { minMessages: 50, minTurns: 2 }) + expect(page.hasMore).toBe(true) + expect(page.events.some(event => event.type === 'turn/start')).toBe(false) + expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBe(ending) + expect(changed.mock.calls.map(([, key, value]) => [key, value])).toEqual([['conversationContinuation', null], ['conversationContinuation', ending]]) + expect(ctx.sessionProjections.checkpoint(session).conversationContinuation) + .toEqual({ ver: 4, seq: ending, val: { admitted: false, failedAttempt: false, ending } }) + session.append('turn/start', { turn: 2 }) + expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull() + expect(changed).toHaveBeenCalledTimes(3) + await fiber.dispose() + expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('conversationContinuation') +}) + +it('restores valid, absent and outdated checkpoints through the registry', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.sessionProjections.register(definition) + const session = ctx.sessions.create(SessionId('restored')) + const log = events(failed('SERVER')) + const checkpoint = { conversationContinuation: { ver: 4, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } } + const restore = (rows: typeof checkpoint | Record, tail: readonly SessionEvent[], base: number) => + ctx.sessionProjections.restore(rows, tail, SessionLogOffset(base), session.header, session.inheritedEventCount) + expect(restore(checkpoint, log.slice(3), 3).snapshot.values.conversationContinuation).toBe(5) + expect(restore({}, log, 0).snapshot.values.conversationContinuation).toBe(5) + const outdated = { conversationContinuation: { ...checkpoint.conversationContinuation, ver: 3 } } + expect(restore(outdated, log, 0).snapshot.values.conversationContinuation).toBe(5) + expect(() => restore(outdated, log.slice(3), 3)).toThrow('re-read from seq 0') + expect(() => restore({}, log.slice(3), 3)).toThrow('re-read from seq 0') +}) + +it('admits empty retry messages while ignoring empty ordinary and non-empty retry messages', () => { + const base = events(failed('SERVER')) + const input = base.find(event => event.type === 'user/message')! + const empty: SessionEvent<'user/message'> = { ...input, type: 'user/message', data: createUserMessage({ + content: [], source: { kind: 'user' }, + }) } + expect(fold(base.map(event => event === input ? empty : event)).ending).toBeNull() + const retry: SessionEvent<'user/message'> = { ...empty, data: createUserMessage({ + content: [], source: { kind: 'retry', rpcId: 'retry' as import('../src/types.ts').SessionRequestId }, + }) } + const nonEmpty: SessionEvent<'user/message'> = { ...retry, data: { ...retry.data, + content: [{ type: 'text', text: 'not an empty retry' }], + } } + expect(fold(base.map(event => event === input ? nonEmpty : event)).ending).toBeNull() + const replacement: SessionEvent<'user/message'> = { ...retry, + surfaceOp: { op: 'replace', startSeq: SessionSeq(0), endSeq: SessionSeq(1) }, + } + expect(fold(base.map(event => event === input ? replacement : event)).ending).toBeNull() + expect(fold(base.map(event => event === input ? retry : event)).ending).toBe(5) + const admitted = definition.apply(definition.init(), retry) + expect(definition.apply(admitted, retry)).toBe(admitted) +}) diff --git a/packages/api/session-controller/tests/empty-prompt.host.spec.ts b/packages/api/session-controller/tests/empty-prompt.host.spec.ts new file mode 100644 index 0000000000..778b3fdee5 --- /dev/null +++ b/packages/api/session-controller/tests/empty-prompt.host.spec.ts @@ -0,0 +1,123 @@ +/** Empty prompt admission over the real AgentLoop and the Session Remote owner. */ +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, LlmError } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { expect, it, onTestFinished } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' +import { createSessionTestRemote } from './test-remote.ts' + +async function setup() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const fail = (): never => { throw new LlmError('busy', 'SERVER') } + const adapter = new MockAdapter([fail, fail, textResponse('done')]) + ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter)) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/workspace' }) + const agent = await ctx.agentLoop.create(SessionId('empty-prompt'), { provider: 'mock', model: 'mock' }) + const request = (id: string, mode: SessionPromptRequest['mode'] = 'queue'): SessionPromptRequest => ({ + sessionId: agent.id, requestId: id as SessionRequestId, content: [], mode, + }) + return { ctx, remote, agent, adapter, request } +} + +it('records empty retry user messages without duplicate requests', async () => { + const { remote, agent, adapter, request } = await setup() + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + const first = request('continue-1') + expect((await remote.prompt(first)).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect((await remote.prompt(first)).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect((await remote.prompt(request('continue-2'))).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(3) + const events = agent.session.snapshotEvents() + const messages = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? [event.data] : []) + expect(messages).toHaveLength(1) + const retries = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'retry' ? [event.data] : []) + expect(retries).toHaveLength(2) + expect(retries).toMatchObject([ + { content: [], source: { kind: 'retry', rpcId: 'continue-1' } }, + { content: [], source: { kind: 'retry', rpcId: 'continue-2' } }, + ]) + const controls = events.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : []) + .filter(message => message.source.kind === 'retry') + expect(retries.map(retry => retry.id)).toEqual(controls.map(control => control.id)) + expect(adapter.requests[1]!.messages.filter(message => message.role === 'user').map(message => message.content)) + .toEqual([[{ type: 'text', text: 'original task' }], []]) + expect(adapter.requests[2]!.messages.filter(message => message.role === 'user').map(message => message.content)) + .toEqual([[{ type: 'text', text: 'original task' }], [], []]) + expect(events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])).toEqual(['error', 'error', 'completed']) + expect(events.filter(event => event.type === 'turn/start')).toHaveLength(3) + expect((await remote.prompt(request('after-completion'))).ok).toBe(false) +}) + +it('refuses empty fresh sessions, steering, queued human input and maintenance without waking work', async () => { + const { remote, agent, adapter, request } = await setup() + expect((await remote.prompt(request('empty-fresh'))).ok).toBe(false) + expect(adapter.requests).toHaveLength(0) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + expect((await remote.prompt(request('empty-steer', 'steer'))).ok).toBe(false) + const pending = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + agent.inbox.append('next-turn', pending) + expect((await remote.prompt(request('empty-queued'))).ok).toBe(false) + agent.inbox.clear() + agent.inject(pending) + expect((await remote.prompt(request('empty-steering'))).ok).toBe(false) + agent.inbox.clear() + await agent.runMaintenance(async () => { + expect((await remote.prompt(request('empty-maintenance'))).ok).toBe(false) + }) + expect(adapter.requests).toHaveLength(1) +}) + +it('consumes injected context with the empty prompt', async () => { + const { ctx, remote, agent, adapter, request } = await setup() + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + agent.inject(createUserMessage({ content: [{ type: 'text', text: 'approval context' }], source: { kind: 'user-approval' } })) + agent.model.select({ provider: 'mock', model: 'changed' }) + expect((await remote.prompt(request('empty-context'))).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('approval context') + expect(adapter.requests[1]!.model).toBe('changed') + expect(agent.inbox.nextStep).toHaveLength(0) + expect(ctx.sessionProjections.snapshot(agent.session).values.conversationContinuation).not.toBeNull() +}) + +it('deduplicates empty continuation after claim and before user-message admission', async () => { + const { ctx, remote, agent, adapter, request } = await setup() + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + const claimed = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + ctx.on('agent/pre-step', async ({ messages }, next) => { + if (messages.some(message => message.source.kind === 'retry')) { + claimed.resolve(undefined) + await release.promise + } + return next() + }) + const continuation = request('continue-once') + expect((await remote.prompt(continuation)).ok).toBe(true) + await claimed.promise + expect(agent.inbox.nextTurn).toEqual([]) + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'retry')).toBe(false) + expect((await remote.prompt(continuation)).ok).toBe(true) + release.resolve(undefined) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : []) + .filter(message => message.source.kind === 'retry')).toHaveLength(1) +}) 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 49e0b3210d..3bdf40c59b 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -875,7 +875,7 @@ async function modelFor(ctx: Context, session: Session): Promise { } describe('sessions.prompt synchronous rejection', () => { - it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => { + it('rejects whitespace-only text parts before delivery or Session events', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -925,7 +925,6 @@ describe('sessions.prompt synchronous rejection', () => { }) const initialEvents = session.snapshotEvents() const rejectedContent: readonly SessionPromptRequest['content'][] = [ - [], [{ type: 'text', text: '' }], [{ type: 'text', text: ' \t\n' }, { type: 'text', text: '' }], ] 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 fe8fc245c2..aa4ae6a702 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -417,11 +417,16 @@ describe('session.history projections block', () => { .toBe(false) seedMessages(session, 1) - const changed = await iterator.next() - expect(changed).toMatchObject({ - done: false, - value: { type: 'projection', changes: [{ key: 'sessionListMetadata' }] }, - }) + let changed = await iterator.next() + while (!changed.done && changed.value.type === 'projection' + && !changed.value.changes.some(change => change.key === 'sessionListMetadata')) { + expect(changed.value.changes.map(change => change.key)).not.toContain('test/internal-count') + changed = await iterator.next() + } + if (changed.done || changed.value.type !== 'projection') throw new Error('control stream ended before its projection') + const keys = changed.value.changes.map(change => change.key) + expect(keys).toContain('sessionListMetadata') + expect(keys).not.toContain('test/internal-count') abort.abort() await iterator.return?.() @@ -527,7 +532,7 @@ describe('session.list projections column', () => { expect(excludedApply).not.toHaveBeenCalled() }) - it('carries every registered summary key, which is only its own list metadata when no other unit is registered', async () => { + it('carries list metadata and unavailable retry eligibility through registered summary keys', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) const response = await (await remote(ctx)).list(request({})) @@ -535,7 +540,8 @@ describe('session.list projections column', () => { const row = response.value.items.find(item => item.sessionId === session.id) expect(row?.projections?.kind).toBe('sequenced') expect(Object.keys(row?.projections?.values ?? {})).toEqual([...ctx.sessionProjections.summaryKeys()]) - expect(Object.keys(row?.projections?.values ?? {})).toEqual(['sessionListMetadata']) + expect(Object.keys(row?.projections?.values ?? {})).toEqual(['conversationContinuation', 'sessionListMetadata']) + expect(row?.projections?.values.conversationContinuation).toBeNull() }) it('serves the summary projection hints from the cache with zero log loads', async () => { @@ -754,11 +760,12 @@ describe('Session control projection frames', () => { iterable: AsyncIterable, count: number, abort: AbortController, + keys: readonly string[], ): Promise { const frames: SessionControlFrame[] = [] for await (const frame of iterable) { frames.push(frame) - if (frames.filter(candidate => candidate.type === 'projection').length >= count) abort.abort() + if (frames.filter(candidate => candidate.type === 'projection' && candidate.changes.some(change => keys.includes(change.key))).length >= count) abort.abort() } return frames } @@ -772,7 +779,7 @@ describe('Session control projection frames', () => { await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() const stream = proxy.control(abort.signal) - const collected = collect(stream, 3, abort) + const collected = collect(stream, 3, abort, ['test/last-user', 'sessionListMetadata']) const now = vi.spyOn(Date, 'now').mockReturnValue(100) seedMessages(session, 1) @@ -789,6 +796,7 @@ describe('Session control projection frames', () => { { type: 'projection', sessionId: session.id, seq: 0, changes: [ { key: 'test/last-user', val: { text: 'm0' } }, + { key: 'conversationContinuation', val: null }, { key: 'sessionListMetadata', val: { blank: true, lastPromptAt: 100 } }, ], }, @@ -829,6 +837,7 @@ describe('Session control projection frames', () => { value: { type: 'projection', seq: 0, changes: [ { key: 'test/last-user', val: { text: 'm0' } }, + { key: 'conversationContinuation', val: null }, { key: 'sessionListMetadata', val: { blank: true, lastPromptAt: 100 } }, ], }, diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 4301be69d6..cf3a6b94cb 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -1010,6 +1010,29 @@ describe('fork', () => { }) }) + it('gives repeated forks distinct titles past numbers listed sessions use', async ({ bench }) => { + const b = bench() + for (const [id, value] of [['source', 'Roadmap'], ['sibling', 'Roadmap (2)']] as const) { + b.svc.handleControlFrame({ type: 'projection', sessionId: sid(id), changes: [{ key: 'title', val: value }], seq: 2 }) + } + await feedList(b, [{ id: 'source' }, { id: 'sibling' }]) + b.mock.remote.session.rename.mockImplementation((payload) => { + const { title } = payload as { title: string } + return Promise.resolve(ok({ title, seq: 3 })) + }) + + for (const child of ['child-1', 'child-2']) { + b.mock.remote.session.fork.mockResolvedValueOnce(ok({ sessionId: sid(child) })) + await b.svc.fork({ sessionId: sid('source'), increaseTitle: true }) + } + + expect(b.mock.remote.session.rename.mock.calls).toEqual([ + [{ sessionId: 'child-1', title: 'Roadmap (1)' }], + [{ sessionId: 'child-2', title: 'Roadmap (3)' }], + ]) + expect(b.svc.list.getSnapshot().byId[sid('child-2')]?.title).toBe('Roadmap (3)') + }) + it('sends the exact boundary seq verbatim: callers pass real event seqs', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'source', cwd: '/work' }]) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index acadf82099..168fd5a0c7 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -25,7 +25,8 @@ "src/list.ts", "src/media-references.ts", "src/archived-session-gate.ts", - "src/skill-catalog.ts" + "src/skill-catalog.ts", + "src/continuation.ts" ], "references": [ { @@ -126,6 +127,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index aebf4624c5..7c16a75482 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -39,15 +39,19 @@ "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 0cf239bd87..873fb7dc94 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-terminal-controller", "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -62,7 +62,7 @@ "@deepseek-ai/schemastery": "workspace:~", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/dsh-subprocess": "workspace:*", @@ -92,9 +92,13 @@ "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ] } diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 05bd18da51..a534bfeb96 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -64,17 +64,21 @@ "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-native-command": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index 5e0761a952..5e39822c0a 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -56,7 +56,7 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" @@ -81,9 +81,13 @@ "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ] } diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 322f35c396..28aae50c7b 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 749f557959..79c35fd196 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -19,7 +19,7 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"], + "files": ["lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "lib/**/*.js.map", "src"], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:*", diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index fa44d7262a..108eb792ee 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -28,7 +28,10 @@ "files": [ "lib/index.js", "lib/worker/profile-resolution-bootstrap.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 291cc2da01..9a50bff2a0 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json index a754166669..1560233646 100644 --- a/packages/boot/config-editor/package.json +++ b/packages/boot/config-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-config-editor", "description": "Persist plugin configuration through profile patches and Loader reconciliation", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index 404f80021e..5e6a517cd7 100644 --- a/packages/boot/hmr/package.json +++ b/packages/boot/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hmr", "description": "Coordinated module and profile configuration hot reload", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "author": "Shigma ", "license": "MIT", diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 2b95a68b06..5a187dc4ac 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: ae0905aa47cc971d - zh: b22fc6e831fac136 + en: c4a9acdb98de091d + zh: 417d8bafe75157ec /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 483285658e267dd5 zh: 846bec2daac59a96 @@ -48,8 +48,8 @@ en: c29f60be7f3edc1a zh: d9b9388b96ef95cc /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work: - en: d26075545fe1e990 - zh: 068c07d642843c94 + en: 89306b478cb75b3b + zh: f2f50c88114fec99 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior: en: 3fde425e82bbd0f4 zh: 531e2365e7d67480 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index a06788d42a..cccb096c15 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -53,9 +53,9 @@ 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. +`listBundles` sets `update` to how `updateBundle` replaces a bundle: `registry` for a third-party profile dependency saved as a registry spec under its own name, not an `npm:` alias, and `git` for one saved as a Git spec pinned to a full 40-character commit; either needs a manifest semver version and no plugin management. A Git spec saved with a branch, a tag, or a range is not updatable, because reinstalling it after a failed update would not bring back the removed version. `listVersions(name, options)` reads the published versions newer than a `registry` bundle. 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, and a `git` bundle is refused as `not-updatable`, since a Git repository publishes no version list; a plugin index lists its newer versions. -`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. +`updateBundle(name, version, options)` replaces a bundle `listBundles` lists with `update` by an exact newer version; an older version is refused. A `registry` bundle updates to `name@version`, and `options.spec` is refused. Its 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. A `git` bundle updates only to the Git spec `options.spec` names, which must pin a full commit of the repository and `path:` directory the installed copy pins; the comparison ignores the form pnpm saved, so the SSH form saved for an HTTPS repository it could not read, and the `github:`, `gitlab:`, or `bitbucket:` shortcut saved for a known host, match their HTTPS form. pnpm reads a Git package only while installing it, so with HMR only the repository's host is checked first: when `git ls-remote` fails on a network error or times out within `githubConnectionTimeoutMs`, the update stops at `step: 'check'` with `failedAt: 'spec-host'` and the profile unchanged, since an unreachable host would fail the target and the reinstallation alike; a check Git cannot run, or one the repository refuses without credentials, is left to pnpm. The installation checks the bundle declaration and compatibility. Either target installation runs with `expected: { name, version }`, so a target that installs another package or another version is undone before its bundle loads and fails with `unexpected-package`, and one without a bundle declaration is undone the same way and fails with `not-bundle`. With HMR the update removes the installed version, so its plugins stop, then installs the target with the current selection: `name@version` with `saveExact` from the registry that answered, or the Git spec as written. 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, and reported as `restore`: a registry copy from the registry it was installed from in this process, else over the configured plan as its removal asks, and a Git copy from the spec the profile saved; 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. A stopped installation streams that failure under its `requestId` as the installation's only `plugin-manager/install-log` run, named by the `git ls-remote` command line with any URL user information removed; a timed-out check reports no exit code. The answer's `packageResult` stays a plain result, without the command line. 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. @@ -65,7 +65,7 @@ Registries are asked in turn. The plan starts at `options.registry`, else the co The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries. -`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; a pnpm run that fails without printing streams the diagnostic its log carries, so the details panel is not left empty. `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, `bundle` names the package a finished run added, and `version` its manifest version, which can differ from the version `inspect` read, for example when pnpm's `minimumReleaseAge` holds back a recent release. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. +`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; a pnpm run that fails without printing streams the diagnostic its log carries, so the details panel is not left empty. `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, `bundle` names the package a finished run added, and `version` its manifest version, which can differ from the version `inspect` read, for example when pnpm's `minimumReleaseAge` holds back a recent release. `options.expected` names the package and manifest version the spec must install: a run that leaves that package missing or at another version, or that adds or changes another direct dependency, is undone like a compatibility rejection, with `package.json`, `pnpm-lock.yaml`, and `node_modules` restored, before any bundle loads, and fails with `unexpected-package`; one whose package declares no bundle is undone the same way and fails with `not-bundle`, and one whose bundle patches cannot load fails as an operation error. `packageResult.unmet` names that code, and the output names what the run installed. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. `waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation. @@ -169,8 +169,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. - An approved git key stays in `pnpm-workspace.yaml` after the plugin moves to a newer commit; nothing removes stale approvals. - 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. +- A registry 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. +- Only the reachability of a Git target's host is checked before the installed copy is removed, and only through Git: a host that only pnpm's own proxy settings reach is refused as unreachable. A target whose commit is missing, that needs credentials pnpm lacks, declares no bundle, rejects the running DSH, or installs another package or version costs a stop and a reinstallation. The reinstallation uses the saved spec, from which the user information of an http(s) URL was removed, so a private repository needs a Git credential helper or the SSH form. A copy the SSH fallback saved for an HTTPS URL with a port loses the port, so its origin no longer matches the index's HTTPS spec and its updates are refused. - The Git transport probe keeps the parent console on Windows, so an SSH host-key or passphrase prompt can appear in the launching terminal until `gitTransportTimeoutMs`. On POSIX a host missing from `known_hosts` fails the SSH probe, so installation stays on HTTPS until the host key is accepted once, for example with `ssh -T git@`. A credential-helper dialog or ssh-agent confirmation, such as a Touch ID or `ssh-add -c` dialog, that takes longer than the deadline counts as a failed probe, and an HTTPS probe that times out lets a reachable SSH form be saved to the profile. A private repository waits up to twice `gitTransportTimeoutMs` before pnpm starts. The SSH form always uses the `git` user and SSH's default port; other SSH endpoints need a `url..insteadOf` rule. When both probes fail, the diagnostic is pnpm's HTTPS failure; the SSH probe's reason is not reported. - 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. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index db1c756fae..cfc0c99f3a 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -53,9 +53,9 @@ 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 版本,不会列出。 +`listBundles` 用 `update` 标明 `updateBundle` 如何替换组合包:以自身包名、按注册表 spec 保存(不是 `npm:` 别名)的第三方 profile 依赖为 `registry`,按固定到完整 40 位提交的 Git spec 保存的为 `git`;两者都要求清单声明 semver 版本,且不承载插件管理。以分支、标签或范围保存的 Git spec 不可更新,因为更新失败后重新安装它无法恢复被移除的版本。`listVersions(name, options)` 读取 `registry` 组合包较新的已发布版本。一次 `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 版本,不会列出;`git` 组合包以 `not-updatable` 拒绝,因为 Git 仓库不发布版本列表,其新版本由插件索引列出。 -`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,因此取消无法中止它。 +`updateBundle(name, version, options)` 把 `listBundles` 标有 `update` 的组合包替换为一个更新的确切版本;更旧的版本会被拒绝。`registry` 组合包更新到 `name@version`,`options.spec` 会被拒绝。其目标版本先按与 `listVersions` 相同的计划查询,版本不存在、未声明组合包,或其 `engines.dsh` 或 DSH peer 拒绝当前 DSH 时,更新停在 `step: 'check'`,profile 保持不变。`git` 组合包只更新到 `options.spec` 指定的 Git spec,该 spec 必须固定到一个完整提交,其仓库与 `path:` 目录须与已安装副本固定的相同;比较忽略 pnpm 保存的形式,因此为其无法读取的 HTTPS 仓库保存的 SSH 形式,以及为已知主机保存的 `github:`、`gitlab:` 或 `bitbucket:` 简写,都与其 HTTPS 形式相匹配。pnpm 只在安装时读取 Git 包,因此有 HMR 时预先只检查仓库所在的主机:`git ls-remote` 因网络错误失败或超出 `githubConnectionTimeoutMs` 时限时,更新停在 `step: 'check'`,标记 `failedAt: 'spec-host'`,profile 保持不变,因为主机不可达时,目标的安装与重新安装同样会失败;Git 无法运行的检查,或仓库因未提供凭据而拒绝的检查,交给 pnpm 处理。安装过程检查组合包声明与兼容性。两种目标的安装都带 `expected: { name, version }` 运行,因此安装了另一个包或另一个版本的目标会在其组合包加载前被撤销,并以 `unexpected-package` 失败;没有组合包声明的目标以同样方式被撤销,并以 `not-bundle` 失败。有 HMR 时,更新先移除已安装版本,使其插件停止,再以当前的选中状态安装目标:从作答的注册表以 `saveExact` 安装 `name@version`,或按原样安装 Git spec。该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再重新安装被移除的版本,结果以 `restore` 报告:注册表副本从本进程安装它所用的注册表重新安装,没有记录时按配置的计划安装,与移除的做法一致;Git 副本从 profile 保存的 spec 重新安装;重新安装也失败时,包处于未安装状态。移除或安装被拒绝(例如锁等待超时)时,报告为该步骤失败。没有 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'`。被停止的安装会以调用方的 `requestId` 把该失败转发为这次安装唯一的 `plugin-manager/install-log` 运行,其命令行是去掉 URL 用户信息的 `git ls-remote`;因超时结束的检查不报告退出码。应答中的 `packageResult` 仍是普通结果,不含该命令行。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 @@ -65,7 +65,7 @@ HTTP(S) Git URL 指向 pnpm 没有 HTTPS 到 SSH 回退的主机(即 `hosted-g 可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。 -`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;未打印任何输出就失败的 pnpm 运行转发其日志中的诊断,详情面板因而不会为空。每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包,`version` 给出它的清单版本,它可能与 `inspect` 读到的版本不同,例如 pnpm 的 `minimumReleaseAge` 暂缓了最近的发布。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 +`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;未打印任何输出就失败的 pnpm 运行转发其日志中的诊断,详情面板因而不会为空。每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包,`version` 给出它的清单版本,它可能与 `inspect` 读到的版本不同,例如 pnpm 的 `minimumReleaseAge` 暂缓了最近的发布。`options.expected` 指定该 spec 必须安装的包与清单版本:使该包缺失或处于其他版本、或新增或改动了其他直接依赖的运行,会像兼容性拒绝一样被撤销,在任何组合包加载前恢复 `package.json`、`pnpm-lock.yaml` 与 `node_modules`,并以 `unexpected-package` 失败;所安装的包未声明组合包的运行以同样方式被撤销,并以 `not-bundle` 失败,组合包 patch 无法加载的运行则以操作错误失败。`packageResult.unmet` 给出该错误码,输出给出该运行安装的内容。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 `waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。 @@ -169,8 +169,9 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, - Web 一次批准显示出来的整组待决定包,没有逐包选择。 - 插件移到更新的提交后,已批准的 git 键仍留在 `pnpm-workspace.yaml` 中;没有任何机制移除过期的批准。 - 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。 -- 更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。 +- 注册表更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。 - 更新把移除与安装作为两次 profile 操作运行,其他操作可能在两者之间运行。 +- 在移除已安装副本之前,只检查 Git 目标所在主机的可达性,且只通过 Git 检查:只有经 pnpm 自身的代理设置才能到达的主机会被视为不可达而拒绝。对于提交不存在、需要 pnpm 不具备的凭据、未声明组合包、拒绝当前 DSH 或安装了其他包或版本的目标,代价是插件停止一次并重新安装一次。重新安装使用保存的 spec,其中 http(s) URL 的 user information 已被去掉,因此私有仓库需要 Git 凭据助手或 SSH 形式。对于带端口的 HTTPS URL,SSH 回退保存的副本会丢失端口,因此其仓库来源不再与插件索引中的 HTTPS spec 相匹配,其更新会被拒绝。 - 在 Windows 上,Git 传输探测沿用父进程的控制台,SSH 的主机密钥或密码提示可能出现在启动终端中,直到 `gitTransportTimeoutMs` 到期。在 POSIX 上,`known_hosts` 中没有的主机会让 SSH 探测失败,因此在接受一次主机密钥(例如执行 `ssh -T git@`)之前一直走 HTTPS。凭据助手对话框或 ssh-agent 确认(例如 Touch ID 或 `ssh-add -c` 对话框)超过时限时算作探测失败;HTTPS 探测超时时,可达的 SSH 形式会写入 profile。私有仓库在 pnpm 启动前最多等待两倍 `gitTransportTimeoutMs`。SSH 形式固定使用 `git` 用户和 SSH 默认端口,其他 SSH 端点需要 `url..insteadOf` 规则。两种探测都失败时,诊断是 pnpm 的 HTTPS 失败,不报告 SSH 探测失败的原因。 - 依赖需要新的构建脚本批准的新版本会更新失败,并重新安装为原版本;移除该包后通过普通安装批准其脚本。 - 没有 HMR 时,替换包需要重启进程。在 Plugin Manager 之外重新链接仍处于安装状态的包,需要等待重启。 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 075f8adb93..3a2a651a30 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-manager", "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -57,10 +57,14 @@ "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { @@ -71,7 +75,7 @@ "execa": "^10.0.1", "semver": "^7.8.5", "yaml": "^2.9.0", - "zod": "4.4.3", + "zod": "4.6.6", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-plugin-detail": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", diff --git a/packages/boot/plugin-manager/src/diagnostic.ts b/packages/boot/plugin-manager/src/diagnostic.ts index aa79fe5527..1250e8d5cb 100644 --- a/packages/boot/plugin-manager/src/diagnostic.ts +++ b/packages/boot/plugin-manager/src/diagnostic.ts @@ -57,6 +57,7 @@ const operation: z.ZodType = z.discriminatedUnion('phase', [ 'stale-approval': 'stale-approval', 'incompatible-version': 'incompatible-version', 'unsupported-platform': 'unsupported-platform', + 'unexpected-package': 'unexpected-package', 'operation-error': 'operation-error', } satisfies { [K in ManagementError['code']]: K }).optional(), restartReason: z.enum({ diff --git a/packages/boot/plugin-manager/src/github-connection.ts b/packages/boot/plugin-manager/src/github-connection.ts index e2d349c734..9a2b87788a 100644 --- a/packages/boot/plugin-manager/src/github-connection.ts +++ b/packages/boot/plugin-manager/src/github-connection.ts @@ -1,4 +1,4 @@ -/** Bounded GitHub repository checks using the installer's Git configuration and environment. */ +/** Bounded Git repository connection checks using the installer's Git configuration and environment. */ import { mkdir, mkdtemp, open } from 'node:fs/promises' import { join } from 'node:path' import { execa } from 'execa' @@ -19,7 +19,7 @@ export interface GithubConnectionFailure extends PackageResult { readonly argv: readonly string[] } -/** Deadline, diagnostics and cancellation for one read-only GitHub check. */ +/** Deadline, diagnostics and cancellation for one read-only Git repository check. */ export interface GithubConnectionOptions { timeoutMs: number outputBytes: number @@ -30,13 +30,10 @@ export interface GithubConnectionOptions { /** * Check a GitHub repository before pnpm starts, without downloading or building its package. - * Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting. - * Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback. * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked. * @param dir The profile directory where installation runs. * @param options The connection deadline, output bound and operation cancellation. - * @returns A failed check carrying the command line it ran (without URL user information) and its bounded output, with a - * complete log at logPath, or undefined for a reachable repository or an unhandled spec. + * @returns As {@link checkGitConnection}, or undefined for a spec that does not name a GitHub repository. */ export async function checkGithubConnection( spec: ParsedInstallSpec, dir: string, options: GithubConnectionOptions, @@ -44,9 +41,25 @@ export async function checkGithubConnection( if (spec.kind !== 'git') return undefined const host = spec.host.toLowerCase().replace(/:.*$/, '') if (host !== 'github.com' && !host.endsWith('.github.com')) return undefined + return checkGitConnection(spec, dir, options) +} + +/** + * Check a Git repository with `git ls-remote`, without downloading or building its package. A host shorthand is read + * over HTTPS; any other spec over the URL it names. Git reads the profile's Git and proxy configuration without + * invoking credential helpers or prompting, so a repository that needs credentials fails as `unknown`, not as a + * connection failure. Timeout and cancellation terminate its descendants too. + * @param spec The parsed address of a Git repository. + * @param dir The profile directory where installation runs. + * @param options The connection deadline, output bound and operation cancellation. + * @returns A failed check carrying the command line it ran (without URL user information), its bounded output and its + * failure kind, with a complete log at logPath, or undefined for a reachable repository. + */ +export async function checkGitConnection( + spec: ParsedInstallSpec & { readonly kind: 'git' }, dir: string, options: GithubConnectionOptions, +): Promise { const repository = spec.spec.replace(/#.*$/s, '') - .replace(/^github:/i, 'https://github.com/') - .replace(/^gist:/i, 'https://gist.github.com/') + .replace(/^(?:github|gitlab|bitbucket|gist):/i, `https://${spec.host}/`) .replace(/^git\+/i, '') const logRoot = join(dir, '.plugin-manager', 'logs') await mkdir(logRoot, { recursive: true, mode: 0o700 }) @@ -70,7 +83,7 @@ export async function checkGithubConnection( }) if (!result.failed) return undefined if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`) - if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? 'GitHub connection check failed') + if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? 'Git connection check failed') const { size } = await log.stat() const bytes = Buffer.alloc(Math.min(size, options.outputBytes)) await log.read(bytes, 0, bytes.length, size - bytes.length) diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index b1d696ad74..86a0f57ca1 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -31,12 +31,12 @@ import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts' import { classifyInstallFailure } from './install-failure.ts' import { officialBundleInstallTarget } from './official-install-target.ts' -import { dependencySpec, InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' +import { dependencySpec, InvalidInstallSpecError, parseInstallSpec, pinnedGitOrigin, type ParsedInstallSpec } from './install-spec.ts' import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts' import { flatten, writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, recordPendingBuilds } from './build-approval.ts' -import { checkGithubConnection, type GithubConnectionFailure } from './github-connection.ts' +import { checkGitConnection, checkGithubConnection, type GithubConnectionFailure } from './github-connection.ts' import { chooseGitTransport, sshGitSpec } from './git-transport.ts' import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts' import type { @@ -48,7 +48,7 @@ import type { } from './types.ts' export type * from './types.ts' export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts' -export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' +export { InvalidInstallSpecError, parseInstallSpec, pinnedGitOrigin, type ParsedInstallSpec } from './install-spec.ts' /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ export interface Config { @@ -161,18 +161,26 @@ function refused(problem: Extract[' 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 +/** + * An installed bundle an update can replace and its installed version: a registry copy with its parsed registry spec, + * or a Git copy with the saved spec that installs it again and the repository directory it pins. + */ +type UpdateTarget = + | { kind: 'registry'; bundle: B; installed: string; parsed: Extract } + | { kind: 'git'; bundle: B; installed: string; source: string; origin: string } + +/** What an update installs: the target spec, the registry asked first when one is named, and whether the save is exact. */ +interface UpdatePlan { + spec: string + registry: Registry | undefined + saveExact: boolean } /** * 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. + * @returns The bundle, its installed version, and how it was installed; or the refusal's diagnostic. */ function updateRefusal>( bundle: B | undefined, name: string, @@ -184,12 +192,15 @@ function updateRefusal { - return Promise.resolve(this.readBundles().map(bundle => ({ ...bundle, updatable: typeof updateRefusal(bundle, bundle.name) !== 'string' }))) + return Promise.resolve(this.readBundles().map((bundle) => { + const target = updateRefusal(bundle, bundle.name) + return typeof target === 'string' ? bundle : { ...bundle, update: target.kind } + })) } /** The bundle inventory before update eligibility, which is derived from each listed bundle. */ - private readBundles(): Omit[] { + private readBundles(): Omit[] { const manifest = readProfileManifest('dsh', this.profile.dir) const exemptions = readProfileVersionExemptions(this.profile.dir) const selected = bundleSelection(manifest) @@ -500,7 +514,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: Omit[] = [] + const bundles: Omit[] = [] const runtimeVersion = getDshRuntimeVersion() for (const name of names) { const installed = dependencies.includes(name) @@ -662,6 +676,7 @@ export class PluginManager extends TypertRemoteService { 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: [] } + if (target.kind === 'git') return { status: 'refused', problem: 'not-updatable', reason: `${name} is installed from Git, which lists no versions`, 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 @@ -698,15 +713,21 @@ export class PluginManager extends TypertRemoteService { } /** - * 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. + * Replace an installed bundle with a newer version. A registry copy updates to `name@version`, checked on the registry + * first: it must exist, declare a bundle, and accept the running DSH version, or nothing changes. A Git copy updates + * to the Git spec `options.spec` names, which must pin a full commit of the repository directory the copy came from; + * with HMR, a network failure or timeout reaching its repository stops the update before anything changes. pnpm reads + * a Git package only while installing it, so its bundle and compatibility are checked then. Either target + * installation is undone, profile files and modules included, before its bundle loads unless it installs `name` at + * `version`. 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: a registry copy + * from the registry it came from, a Git copy from its saved spec. 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. + * @param version Exact version newer than the installed one. + * @param options The registry asked first, as for `listVersions`; the Git spec of `version`, required for and accepted + * only by a bundle `listBundles` lists with `update: 'git'`; 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 @@ -727,29 +748,34 @@ export class PluginManager extends TypertRemoteService { 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 planned = target.kind === 'git' + ? this.planGitUpdate(name, target.origin, options) + : await this.checkRegistryUpdate(name, version, options) + if ('code' in planned) return refusedBefore(planned) + const { spec, registry, saveExact } = planned 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 }, + saveExact, enabled, expected: { name, version }, + ...registry === undefined ? {} : { registry }, ...requestId === undefined ? {} : { requestId }, })) if (this.ownerContext.get('hmr') === undefined) { announce('installing') return { from, to: version, step: 'install', result: await installTarget() } } + // A Git host that cannot be reached would fail the target and the reinstallation alike, so nothing is removed. + const unreachable = target.kind === 'git' ? await this.gitHostUnreachable(spec) : undefined + if (unreachable !== undefined) { + return { + from, to: version, step: 'check', + result: { + changed: false, application: 'failed', stage: 'install', target: name, failedAt: 'spec-host', packageResult: unreachable, + error: { code: 'operation-error', diagnostic: unreachable.output }, + }, + } + } // 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') @@ -762,13 +788,80 @@ export class PluginManager extends TypertRemoteService { // 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 previous = target.kind === 'git' ? target.source : `${name}@${from}` const restore = await settled('install', previous, this.installBundle(previous, { - saveExact: true, enabled, ...previousRegistry === undefined ? {} : { registry: previousRegistry }, + saveExact, enabled, ...previousRegistry === undefined ? {} : { registry: previousRegistry }, })) return { from, to: version, step: 'install', result, restore } } + /** + * The target of a Git copy's update: the caller's Git spec, pinned to a full commit of the copy's repository directory. + * @param name The package name. + * @param origin The repository directory the installed copy pins. + * @param options The caller's spec and registry. + * @returns The spec and the registry its dependencies are asked from first, or why the update is refused. + */ + private planGitUpdate(name: string, origin: string, options: UpdateBundleOptions | undefined): UpdatePlan | ManagementError { + const spec = options?.spec + const pinned = spec === undefined ? undefined : pinnedGitOrigin(spec) + if (spec === undefined || pinned === undefined) { + return { code: 'invalid-spec', diagnostic: `${name} is installed from Git and updates only to a Git spec pinned to a full commit` } + } + if (pinned !== origin) { + return { code: 'invalid-spec', diagnostic: `${spec} is not in the repository directory ${name} is installed from, ${origin}` } + } + return { spec, registry: this.registryFor(name, options?.registry), saveExact: false } + } + + /** + * Check that the network reaches the repository of a Git update's target. Only a network failure or a timeout + * counts: a check Git cannot run, or one the repository refuses without credentials, leaves the answer to pnpm. + * @param spec The target's pinned Git spec. + * @returns The failed check, or undefined when the host answered or the check could not tell. + */ + private async gitHostUnreachable(spec: string): Promise { + const parsed = parseInstallSpec(spec) + /* v8 ignore next -- planGitUpdate accepts only a pinned Git spec */ + if (parsed.kind !== 'git') return undefined + const env = this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env } + const check = checkGitConnection(parsed, this.profile.dir, { + timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes, signal: this.abort.signal, ...env, + }) + this.packageOperations.add(check) + let failure: GithubConnectionFailure | undefined + try { failure = await check } finally { this.packageOperations.delete(check) } + this.abort.signal.throwIfAborted() + if (failure?.kind !== 'network' && failure?.kind !== 'timeout') return undefined + // The answer keeps a plain PackageResult; the check's command line stays out of the wire fields. + const { argv: _argv, ...packageResult } = failure + return packageResult + } + + /** + * Check a registry copy's update target on the registry plan before anything changes. + * @param name The package name. + * @param version The target version. + * @param options The caller's registry, as for `listVersions`; a spec is refused. + * @returns The exact spec and the registry that answered for it, or why the target cannot replace the bundle. + */ + private async checkRegistryUpdate( + name: string, version: string, options: UpdateBundleOptions | undefined, + ): Promise { + const spec = `${name}@${version}` + if (options?.spec !== undefined) return { code: 'invalid-spec', diagnostic: `${name} is installed from a registry and updates only to ${spec}` } + 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 { code: 'operation-error', diagnostic: checked.reason } + const facts = versionFacts(checked.answer, name, version) + if (!facts.bundle) return { code: 'not-bundle' } + let compatibility + try { compatibility = evaluatePluginCompatibility(facts.manifest, readProfileVersionExemptions(this.profile.dir)) } + catch (error) { return { code: 'operation-error', diagnostic: messageOf(error) } } + if (compatibility !== undefined && !compatibility.exempted) return { code: 'incompatible-version', incompatible: [incompatiblePlugin(compatibility)] } + return { spec, registry: checked.registry, saveExact: true } + } + /** * The registry a version listing or an update asks first. * @param name The package name. @@ -938,11 +1031,16 @@ export class PluginManager extends TypertRemoteService { result.registries.push(registry) used = registry announce('installing', { registry, index: index + 1, total: plan.length }) - run = await this.runPnpm(['add', installSpec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId) + run = await this.runPnpm( + ['add', installSpec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], + control.abort.signal, requestId, options?.expected, + ) result.packageResult = run if (stopped()) throw new InstallCancelledError() // A compatibility refusal is the package's own answer, so no other registry is asked. if (run.incompatible !== undefined) throw new ManagementFailure('incompatible-version', run.incompatible) + // So is a run that installed another package, version, or no bundle where the caller expects one. + if (run.unmet !== undefined) throw new ManagementFailure(run.unmet) // A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0. if (run.exitCode === 0 && run.timedOut !== true) break /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */ @@ -1110,9 +1208,9 @@ export class PluginManager extends TypertRemoteService { return { rows, overrides } } - /** Run one pnpm command in the profile, streaming its output as install-log chunks. */ + /** Run one pnpm command in the profile, streaming its output as install-log chunks; `expected` is the package an `add` must install. */ private async runPnpm( - args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId, + args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId, expected?: InstallBundleOptions['expected'], ): Promise { const jobId = randomUUID() const argv = ['pnpm', ...args] @@ -1122,7 +1220,7 @@ export class PluginManager extends TypertRemoteService { execution: 'service', ...this.profile.packageManager ?? { command: this.pnpmCommand }, signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]), outputBytes: this.outputBytes, activateNewBundles: false, idleTimeoutMs: this.idleTimeoutMs, - lookupTimeoutMs: this.inspectTimeoutMs, + lookupTimeoutMs: this.inspectTimeoutMs, ...expected === undefined ? {} : { expected }, onOutput: (text, stream) => { this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text }) }, diff --git a/packages/boot/plugin-manager/src/install-spec.ts b/packages/boot/plugin-manager/src/install-spec.ts index 2fb6b64cca..e5cfd67c39 100644 --- a/packages/boot/plugin-manager/src/install-spec.ts +++ b/packages/boot/plugin-manager/src/install-spec.ts @@ -1,6 +1,7 @@ /** * Reading an install spec before pnpm sees it: which of pnpm's spec forms it - * takes, and for a registry name whether it is one the registry can accept. + * takes, for a registry name whether it is one the registry can accept, and + * for a Git spec pinned to a commit the repository directory it installs from. * @module @deepseek-ai/dsh-plugin-manager/install-spec */ @@ -91,6 +92,41 @@ export function parseInstallSpec(raw: string): ParsedInstallSpec { return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range } } +/** A full SHA-1 commit name, as a fragment parameter of a Git spec. */ +const FULL_COMMIT = /^[0-9a-f]{40}$/i + +/** + * Where a Git spec pinned to a full commit installs from: the lower-case host and the repository path, without + * transport, user, or `.git`, then `#` and the `path:` directory. The origin ignores the form pnpm saves, so the SSH + * form saved for an HTTPS repository it cannot read, and the host shortcut saved for a known host, have the HTTPS + * form's origin. Equal origins name one repository on a host that serves it at the same path over every transport, + * as Git forges do; on a plain SSH server, an scp-like path is relative to the login's home directory. + * @param spec - an install spec, such as a saved dependency or an update's target. + * @returns the origin, or undefined for a spec that is not Git, or whose fragment is not one full commit and at most + * one `path:` directory. + */ +export function pinnedGitOrigin(spec: string): string | undefined { + let parsed: ParsedInstallSpec + try { parsed = parseInstallSpec(spec) } catch (error) { + /* v8 ignore next -- parseInstallSpec throws nothing but its own refusal */ + if (!(error instanceof InvalidInstallSpecError)) throw error + return undefined + } + const hash = parsed.spec.indexOf('#') + if (parsed.kind !== 'git' || hash === -1) return undefined + const parameters = parsed.spec.slice(hash + 1).split('&') + const paths = parameters.filter(part => part.startsWith('path:')) + const others = parameters.filter(part => !part.startsWith('path:')) + // pnpm reads the last of repeated parameters, so a spec that repeats one names no single origin. + if (paths.length > 1 || others.length !== 1 || !FULL_COMMIT.test(others.join(''))) return undefined + const path = (paths[0]?.slice('path:'.length) ?? '').replace(/^\/+|\/+$/g, '') + const location = parsed.spec.slice(0, hash) + .replace(/^(github|gitlab|bitbucket|gist):/i, (_prefix, shorthand: string) => `${GIT_SHORTHAND_HOSTS[shorthand.toLowerCase()] as string}/`) + .replace(/^git\+/i, '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^@/]+@/, '').replace(/^([^/:]+):(?!\d)/, '$1/') + .replace(/\/+$/, '').replace(/\.git$/i, '').replace(/^[^/]+/, host => host.toLowerCase()) + return `${location}#${path}` +} + /** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */ const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^[^@/:\s]+@[^:/\s]+:/i /** The user information of an http(s) URL, up to the last `@` before the path, which holds a password or access token when present. */ diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index d0ab1dccab..945c20d078 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -53,6 +53,12 @@ export interface PackageOperationOptions { idleTimeoutMs?: number /** Bound on the pre-install registry lookup, in milliseconds; without one a fixed bound applies. */ lookupTimeoutMs?: number + /** + * The bundle package and manifest version the run must install. A run that leaves this package missing, at another + * version, or without a bundle declaration or bundle patches that load, or that adds or changes another direct + * dependency, is undone like a compatibility rejection after it. + */ + expected?: { readonly name: string; readonly version: string } } /** Resolve relative package specs against the caller's directory. @@ -281,16 +287,19 @@ function bundleComponentManifests(manifest: ProfileManifest, dir: string, anchor } /** Execute pnpm inside a profile whose caller already holds the profile write lock. - * Newly installed or updated direct dependencies are checked even when activation is disabled; - * an untouched dependency never blocks an unrelated operation and stays denied at startup. - * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk. + * Newly installed or updated direct dependencies are checked even when activation is disabled. With `expected`, each + * of them must be the expected package, which must be installed at its version with a bundle declaration. Any other + * untouched dependency never blocks an unrelated operation and stays denied at startup. + * A denial before pnpm runs changes nothing; one after it restores the profile manifest and lockfile and reinstalls + * the restored lockfile, while downloaded files stay in the pnpm store. * @param context Launcher-owned profile and resolution locations. * @param args Pnpm arguments, before relative path anchoring. * @param options Output, activation and cancellation policy. * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path. - * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors. + * A compatibility or `expected` denial returns exit code 1 and names what it refused. * Unsupported Host activation returns exit code 1 with named diagnostics; installed dependencies remain, * and no bundle selection changes are saved. Other reconciliation or filesystem errors reject. + * Service output is bounded; CLI output uses inherited descriptors. * The diagnostic file is closed on return or rejection, including preparation and spawn failures. */ export async function runProfilePnpm( @@ -333,6 +342,14 @@ export async function runProfilePnpm( } /** Packages a compatibility check refused; callers render them for their own surface. */ const incompatible: IncompatiblePlugin[] = [] + /** Why the post-install check undoes a successful run. */ + const warnings: string[] = [] + /** What `options.expected` found wrong with the run, `unexpected-package` taking precedence. */ + let unmet: PackageResult['unmet'] + const fail = (code: NonNullable, warning: string): void => { + if (unmet !== 'unexpected-package') unmet = code + warnings.push(warning) + } const rejected = async (warnings: readonly string[], restoration: string): Promise => { const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n` await log.write(diagnostic) @@ -478,7 +495,8 @@ export async function runProfilePnpm( // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection. if (exitCode === 0 && !control.stalled) { const after = readProfileManifest('dsh', dir) - const warnings: string[] = [] + const { expected } = options + let seen = false for (const [name, spec] of Object.entries(directDependencies(after))) { const packageDir = join(dir, 'node_modules', name) const installed = optionalFile(join(packageDir, 'package.json')) @@ -489,6 +507,18 @@ export async function runProfilePnpm( const issues: IncompatiblePlugin[] = [] try { const manifest = readProfileManifest('dsh', packageDir) + const installedAs = `${name}@${String(manifest.version)}` + const wanted = expected === undefined ? undefined : `${expected.name}@${expected.version}` + // The expected package is checked even when the run left it as it was, as adding its saved spec again does. + if (name === expected?.name) { + seen = true + if (installedAs !== wanted) fail('unexpected-package', `${installedAs} is installed where ${String(wanted)} was expected`) + else if (manifest.dsh?.bundle === undefined) fail('not-bundle', `${installedAs} declares no bundle`) + // The caller loads these patches next; one that cannot load is refused here, while its modules can go back. + else for (const file of bundlePatchPaths(packageDir, manifest.dsh.bundle)) loadOverlayPatches('dsh', file) + } else if (wanted !== undefined && !untouched) { + fail('unexpected-package', `${installedAs} is installed where ${wanted} was expected`) + } for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) { const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir)) if (issue !== undefined && !issue.exempted) { @@ -510,6 +540,7 @@ export async function runProfilePnpm( options.onOutput?.(notice, 'stderr') } } + if (expected !== undefined && !seen) fail('unexpected-package', `${expected.name}@${expected.version} is not installed`) if (warnings.length > 0) { // A bundle component's requirements need installed contents, so this rejection lands after pnpm // replaced the tree: restore the files, then reinstall the restored lockfile so the version @@ -553,6 +584,7 @@ export async function runProfilePnpm( return { exitCode, output: output.toString('utf8'), truncated, logPath, ...control.stalled ? { timedOut: true } : {}, ...incompatible.length > 0 ? { incompatible } : {}, + ...unmet === undefined ? {} : { unmet }, } } diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts index 57f7fda5ac..26632d24de 100644 --- a/packages/boot/plugin-manager/src/tools.ts +++ b/packages/boot/plugin-manager/src/tools.ts @@ -24,6 +24,7 @@ const ERROR_STEPS: Record = { 'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.', 'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.', 'unsupported-platform': 'This bundle cannot run on the current Host platform; ask the user to use a supported Host.', + 'unexpected-package': 'The source installed another package or version than requested and was rolled back; report this to the user.', 'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.', } @@ -130,7 +131,7 @@ export function apply(ctx: Context): void { const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) => 'rows' in row // GUI listing and update eligibility do not change the tool's management operations. - ? (({ updatable: _updatable, listedByDefault: _listedByDefault, ...bundle }) => ({ + ? (({ update: _update, listedByDefault: _listedByDefault, ...bundle }) => ({ ...bundle, rows: bundle.rows.map(({ meta: _rowMeta, ...declared }) => declared), }))(row) : row) diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 221607f1e8..f7655644b6 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -36,7 +36,7 @@ export interface IncompatiblePlugin { /** Localizable management failure and optional external diagnostic. */ export interface ManagementError { - code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'unsupported-platform' | 'operation-error' + code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'unsupported-platform' | 'unexpected-package' | 'operation-error' diagnostic?: string /** Present with `incompatible-version`: the packages the running DSH version rejects. */ incompatible?: IncompatiblePlugin[] @@ -101,10 +101,12 @@ 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. + * How `updateBundle` replaces the bundle, absent when it cannot: `registry` for a third-party profile dependency saved + * as a registry spec under its own name, whose newer versions `listVersions` reads; `git` for one saved as a Git spec + * pinned to a full commit, which updates to another commit of the same repository directory. Either needs a semver + * manifest version and no plugin management. */ - updatable: boolean + update?: 'registry' | 'git' readOnlyReason?: ReadOnlyReason error?: ManagementError /** The rows the bundle's patch inserts, in declaration order; empty when the patch cannot be read. */ @@ -154,6 +156,12 @@ export interface PackageResult { timedOut?: boolean /** Present when a compatibility check refused the run: the packages the running DSH version rejects. */ incompatible?: IncompatiblePlugin[] + /** + * Present when an `expected` check refused the run: `unexpected-package` when the run left the expected package + * missing or at another version, or added or changed another direct dependency; otherwise `not-bundle` when the + * expected package declares no bundle. The output names what the run installed. + */ + unmet?: 'unexpected-package' | 'not-bundle' } /** Persisted change and independently observed application outcome. */ @@ -201,6 +209,13 @@ export interface InstallBundleOptions { approvedBuilds?: string[] /** The registry asked first; absent, the configured one. The configured fallbacks follow while a registry is unreachable or stale. */ registry?: Registry + /** + * The package and manifest version the spec must install. An installation that leaves this package missing or at + * another version, or that adds or changes another direct dependency, is undone with its profile files and modules + * before its bundle loads, and fails with `unexpected-package`; one whose package declares no bundle is undone the + * same way and fails with `not-bundle`, and one whose bundle patches cannot load fails as an operation error. + */ + expected?: { readonly name: string; readonly version: string } } /** Where an inspection asks. */ @@ -310,6 +325,11 @@ export interface ListVersionsOptions { export interface UpdateBundleOptions { /** The registry asked first, normally the one that answered `listVersions`; absent, as for `listVersions`. */ readonly registry?: Registry + /** + * The Git spec of the target version: required for a bundle listed with `update: 'git'`, where it must pin a full + * commit of the installed copy's repository directory, and refused for any other. + */ + readonly spec?: string /** Names the update's progress and the target installation's logs; `cancelInstall` stops only that installation. */ readonly requestId?: PluginInstallRequestId } diff --git a/packages/boot/plugin-manager/tests/bundle-detail.spec.ts b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts index dc3ad0c268..732f08e6aa 100644 --- a/packages/boot/plugin-manager/tests/bundle-detail.spec.ts +++ b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts @@ -159,7 +159,7 @@ it('reports a listed bundle whose directory is gone, and keeps failures other th 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: [], + name: 'vanished', official: false, availability: 'profile', enabled: false, installed: true, optional: false, removable: true, rows: [], overrides: [], }]) expect(await failureOf(manager.readBundleDetail('vanished', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'vanished' } }) listing.mockRestore() diff --git a/packages/boot/plugin-manager/tests/github-connection.spec.ts b/packages/boot/plugin-manager/tests/github-connection.spec.ts index e87ac53b8c..1d76e1eaf1 100644 --- a/packages/boot/plugin-manager/tests/github-connection.spec.ts +++ b/packages/boot/plugin-manager/tests/github-connection.spec.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import * as subprocess from 'execa' import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest' -import { checkGithubConnection } from '../src/github-connection.ts' +import { checkGitConnection, checkGithubConnection } from '../src/github-connection.ts' import { parseInstallSpec } from '../src/install-spec.ts' import { isolateGitCommandLineConfig } from './git-environment.ts' @@ -40,7 +40,14 @@ async function fixture() { tasks.push(task) return task } - return { dir, config, controller, check, env } + const checkAny = (spec: string) => { + const parsed = parseInstallSpec(spec) + if (parsed.kind !== 'git') throw new Error(`${spec} is not a Git spec`) + const task = checkGitConnection(parsed, dir, { timeoutMs: 3000, outputBytes: 4096, signal: controller.signal, env }) + tasks.push(task) + return task + } + return { dir, config, controller, check, checkAny, env } } /** The exact command line a check runs for one repository URL. */ @@ -136,7 +143,7 @@ it.each([undefined, 'Git could not start'])('retains a diagnostic when Git write onTestFinished(() => { run.mockRestore() }) const result = await check('github:acme/plugin') expect(result).toMatchObject({ - exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed', + exitCode: 1, kind: 'unknown', output: shortMessage ?? 'Git connection check failed', argv: checkArgv('https://github.com/acme/plugin'), }) expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) @@ -211,3 +218,16 @@ it('does not invoke configured credential helpers or askpass for an authenticati expect(await check('github:acme/private.git')).toMatchObject({ exitCode: 128, kind: 'unknown' }) await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }) }) + +it.each([ + ['gitlab:acme/plugin#main', 'https://gitlab.com/'], + ['bitbucket:acme/plugin#main', 'https://bitbucket.org/'], + [`git+https://git.example/acme/plugin.git#${'a'.repeat(40)}&path:/packages/a`, 'https://git.example/'], + ['git+ssh://git@git.example/acme/plugin.git#main', 'ssh://git@git.example/'], +])('checks the repository of %s on any host', async (spec, prefix) => { + const { checkAny, dir, config } = await fixture() + const repository = join(dir, 'empty.git') + await subprocess.execa('git', ['init', '--bare', repository]) + await writeFile(config, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ${prefix}acme/plugin.git\n insteadOf = ${prefix}acme/plugin\n`) + expect(await checkAny(spec)).toBeUndefined() +}) diff --git a/packages/boot/plugin-manager/tests/install-spec.spec.ts b/packages/boot/plugin-manager/tests/install-spec.spec.ts index 26f591cc6c..f77ce658e0 100644 --- a/packages/boot/plugin-manager/tests/install-spec.spec.ts +++ b/packages/boot/plugin-manager/tests/install-spec.spec.ts @@ -1,7 +1,7 @@ /** Reading install specs and classifying pnpm failures: pure, table-driven. */ import { describe, expect, it } from 'vitest' -import { classifyInstallFailure, InvalidInstallSpecError, parseInstallSpec } from '@deepseek-ai/dsh-plugin-manager' +import { classifyInstallFailure, InvalidInstallSpecError, parseInstallSpec, pinnedGitOrigin } from '@deepseek-ai/dsh-plugin-manager' describe('parseInstallSpec', () => { it('reads registry names with an optional range, scoped or not', () => { @@ -82,3 +82,32 @@ describe('classifyInstallFailure', () => { expect(classifyInstallFailure({ log: '' })).toBe('unknown') }) }) + +describe('pinnedGitOrigin', () => { + const COMMIT = '0123456789abcdef0123456789abcdef01234567' + + it('reads the repository directory of a Git spec pinned to a full commit, whatever form pnpm saved', () => { + for (const spec of [ + `git+https://GitLab.Example.com/g/plugins.git#${COMMIT}&path:/packages/a`, + `git+ssh://git@gitlab.example.com/g/plugins.git#path:/packages/a/&${COMMIT.toUpperCase()}`, + `git@gitlab.example.com:g/plugins.git/#${COMMIT}&path:packages/a`, + ]) { + expect(pinnedGitOrigin(spec)).toBe('gitlab.example.com/g/plugins#packages/a') + } + // pnpm saves a known host's shortcut for the URL it was given. + expect(pinnedGitOrigin(`github:o/r#${COMMIT}&path:/p`)).toBe(pinnedGitOrigin(`git+https://github.com/o/r.git#${COMMIT}&path:/p`)) + expect(pinnedGitOrigin(`https://github.com/a/b#${COMMIT}`)).toBe('github.com/a/b#') + // A port is part of the host, not an scp-like path separator. + expect(pinnedGitOrigin(`git+ssh://git@host:2222/a/b.git#${COMMIT}`)).toBe('host:2222/a/b#') + }) + + it('reads nothing from a spec that is not Git or names no single full commit and directory', () => { + for (const spec of [ + '', 'dsh-x@1.0.0', '/plugins/dsh-x', 'git+https://host/a/b.git', 'git+https://host/a/b.git#main&path:/a', + `git+https://host/a/b.git#${COMMIT.slice(1)}`, `git+https://host/a/b.git#${COMMIT}&path:/a&path:/b`, + `git+https://host/a/b.git#${COMMIT}&${COMMIT}`, + ]) { + expect(pinnedGitOrigin(spec)).toBeUndefined() + } + }) +}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 8187c45a9b..ae71a3db8f 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -81,7 +81,8 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre // What pnpm's own configuration names is read from pnpm before every registry plan; the fixture answers npm's own registry. const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/') const connection = vi.spyOn(githubConnection, 'checkGithubConnection').mockResolvedValue(undefined) - onTestFinished(() => { registry.mockRestore(); connection.mockRestore() }) + const gitConnection = vi.spyOn(githubConnection, 'checkGitConnection').mockResolvedValue(undefined) + onTestFinished(() => { registry.mockRestore(); connection.mockRestore(); gitConnection.mockRestore() }) let stopHmr = async () => {} if (reload === 'live') { await ctx.plugin(Timer) @@ -89,7 +90,7 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre stopHmr = () => owner.dispose() await ctx.hmr.runExclusive(async () => {}) } - return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection } + return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection, gitConnection } } it.each(['live', 'startup'] as const)('refuses an unsupported bundle before saving or applying it with %s reload', async (reload) => { @@ -414,12 +415,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, updatable: false, readOnlyReason: 'management-required', + name: 'core', version: '1.0.0', enabled: true, installed: false, official: false, availability: 'profile', optional: false, removable: 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, updatable: true, + name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, official: false, availability: 'profile', optional: false, removable: true, update: 'registry', meta: { title: 'extra' }, rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [], }, @@ -441,7 +442,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, updatable: 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, update: 'registry', meta: { title: 'described', description: 'Describes itself.' }, rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'], }) @@ -584,7 +585,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, updatable: true, + name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, update: 'registry', rows: [], overrides: [], ...expected, }) }) @@ -1420,7 +1421,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, updatable: false, + enabled: false, installed: false, official: true, availability: 'installation', optional: true, removable: false, rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [], }) expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' }) @@ -1810,7 +1811,7 @@ it('lists absent Official entries offline without resolving their packages or co expect(bundles.filter(bundle => bundle.name === entry.packageName)).toHaveLength(1) expect(bundles.filter(bundle => bundle.name === entry.packageName)).toMatchObject([{ name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false, - removable: false, updatable: false, rows: [], overrides: [], meta: entry.meta, + removable: false, rows: [], overrides: [], meta: entry.meta, ...entry.platforms !== undefined && !entry.platforms.includes(process.platform) ? { listedByDefault: false, error: { code: 'unsupported-platform' } } : {}, installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() }, @@ -2093,17 +2094,20 @@ it('refuses to list versions of a package not installed from a registry', async 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')) + expect(await manager.listVersions('linked')).toEqual(refusal('linked is installed neither from a registry under its own name nor from a Git commit')) // 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')) + expect(await manager.listVersions('linked')).toEqual(refusal('linked is installed neither from a registry under its own name nor from a Git commit')) 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', 'package.json'), JSON.stringify({ name: 'extra', version: '1.0.0', dsh: BUNDLE })) 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 }) + const managed = (await manager.listBundles()).find(item => item.name === 'extra') + expect(managed).toMatchObject({ readOnlyReason: 'management-required' }) + expect(managed).not.toHaveProperty('update') expect(await manager.listVersions('extra')).toEqual(refusal('extra carries plugin management and cannot be removed')) expect(view).not.toHaveBeenCalled() }) @@ -2143,6 +2147,8 @@ it('updates a registry bundle by removing it and installing the exact newer vers 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}`]]) + // The run itself is undone unless it installs this package at this version. + expect(pnpm.mock.calls[1]?.[2]).toMatchObject({ expected: { name: 'extra', version: '1.2.0' } }) expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.2.0') expect(ctx.get('managedProbe')).toBe(true) }) @@ -2318,3 +2324,193 @@ it('omits exemption identity when only the runtime version is invalid', async () expect(fact.version).toBeUndefined() } }) + +const GIT_REPOSITORY = 'git+https://gitlab.example/g/plugins.git' +/** A Git spec of the `extra` directory pinned to one commit. */ +const gitSpec = (commit: string): string => `${GIT_REPOSITORY}#${commit.repeat(40)}&path:/packages/extra` +const GIT_A = gitSpec('a') +const GIT_B = gitSpec('b') + +/** Save the fixture's `extra` 1.0.0 as installed from `spec`. */ +function installedFromGit(dir: string, spec = GIT_A): void { + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, extra: spec } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) +} + +/** + * A pnpm stand-in that removes the package a `remove` names and installs the package `installs` maps a Git spec to. A run + * whose package differs from the one the caller expects, or declares no bundle, is refused without changes, as + * `runProfilePnpm` undoes it. + */ +function gitPnpm( + dir: string, bundle: (name: string, rows: unknown[]) => void, + installs: Readonly>, +) { + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args, options) => { + const manifest = readProfileManifest('test', dir) + const spec = String(args[1]) + const installed = installs[spec] + if (args[0] === 'remove') { + delete manifest.dependencies?.[spec] + rmSync(join(dir, 'node_modules', spec), { recursive: true, force: true }) + } else if (installed === undefined) { + return { exitCode: 1, output: `ERR_PNPM_GIT_CHECKOUT_FAILED ${spec}`, truncated: false, logPath: '/install.log', kind: 'unknown' as const } + } else if (options.expected !== undefined && `${installed.name}@${installed.version}` !== `${options.expected.name}@${options.expected.version}`) { + const found = `${installed.name}@${installed.version}` + const output = `dsh: installation rejected: ${found} is installed where ${options.expected.name}@${options.expected.version} was expected` + return { exitCode: 1, output, truncated: false, logPath: '/install.log', kind: 'unknown' as const, unmet: 'unexpected-package' as const } + } else if (options.expected !== undefined && installed.bundle === false) { + const output = `dsh: installation rejected: ${installed.name}@${installed.version} declares no bundle` + return { exitCode: 1, output, truncated: false, logPath: '/install.log', kind: 'unknown' as const, unmet: 'not-bundle' as const } + } else { + bundle(installed.name, [{ id: `${installed.name}-row`, name: './plugin.mjs', config: { service: `${installed.name}Probe` } }]) + writeFileSync(join(dir, 'node_modules', installed.name, 'package.json'), JSON.stringify({ name: installed.name, version: installed.version, dsh: BUNDLE })) + manifest.dependencies = { ...manifest.dependencies, [installed.name]: spec } + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' } + }) + onTestFinished(() => { pnpm.mockRestore() }) + return pnpm +} + +it('lists a copy pinned to a Git commit for an update and refuses to list its versions', async () => { + const { manager, dir } = await fixture() + const view = viewAnswers(extraRegistry) + installedFromGit(dir) + expect((await manager.listBundles()).find(item => item.name === 'extra')).toMatchObject({ source: GIT_A, update: 'git' }) + expect(await manager.listVersions('extra')).toEqual({ + status: 'refused', problem: 'not-updatable', reason: 'extra is installed from Git, which lists no versions', attempts: [], + }) + // A branch moves, so reinstalling it would not bring back the removed version. + installedFromGit(dir, `${GIT_REPOSITORY}#main&path:/packages/extra`) + expect((await manager.listBundles()).find(item => item.name === 'extra')?.update).toBeUndefined() + expect((await manager.updateBundle('extra', '1.1.0', { spec: GIT_B })).result.error).toEqual({ + code: 'invalid-spec', diagnostic: 'extra is installed neither from a registry under its own name nor from a Git commit', + }) + expect(view).not.toHaveBeenCalled() +}) + +it('updates a Git copy by removing it and installing the expected package from another commit with its selection', async () => { + const { ctx, manager, dir, bundle } = await fixture() + const view = viewAnswers(extraRegistry) + installedFromGit(dir) + const pnpm = gitPnpm(dir, bundle, { [GIT_B]: { name: 'extra', version: '1.1.0' } }) + const steps: string[] = [] + ctx.on('plugin-manager/update-state', ({ step }) => { steps.push(step) }) + const requestId = '0b6f3c2a-9d8e-4f71-a5b4-3c2d1e0f9a8b' as PluginInstallRequestId + const updated = await manager.updateBundle('extra', '1.1.0', { spec: GIT_B, requestId }) + expect(updated).toMatchObject({ from: '1.0.0', to: '1.1.0', step: 'install', result: { application: 'applied', bundle: 'extra', version: '1.1.0', enabled: true } }) + expect(updated.restore).toBeUndefined() + expect(steps).toEqual(['checking', 'removing', 'installing']) + // The Git target names its own commit, so nothing is viewed on a registry and the save is not exact. + expect(pnpm.mock.calls.map(call => call[1])).toEqual([['remove', 'extra'], ['add', GIT_B]]) + expect(pnpm.mock.calls[1]?.[2]).toMatchObject({ expected: { name: 'extra', version: '1.1.0' } }) + expect(view).not.toHaveBeenCalled() + expect(readProfileManifest('test', dir).dependencies?.extra).toBe(GIT_B) + expect(ctx.get('extraProbe')).toBe(true) +}) + +it.each([ + ['another package', { name: 'other', version: '1.1.0' }], + ['another version', { name: 'extra', version: '1.0.5' }], +] as const)('installs the removed Git copy again from its saved spec when the target installs %s', async (_name, installed) => { + const { manager, dir, bundle } = await fixture() + installedFromGit(dir) + const pnpm = gitPnpm(dir, bundle, { [GIT_A]: { name: 'extra', version: '1.0.0' }, [GIT_B]: installed }) + const updated = await manager.updateBundle('extra', '1.1.0', { spec: GIT_B }) + expect(updated).toMatchObject({ + step: 'install', restore: { application: 'applied', version: '1.0.0' }, + result: { + application: 'failed', error: { code: 'unexpected-package' }, + packageResult: { unmet: 'unexpected-package', output: expect.stringContaining(`${installed.name}@${installed.version} is installed`) as string }, + }, + }) + expect(pnpm.mock.calls.map(call => call[1])).toEqual([['remove', 'extra'], ['add', GIT_B], ['add', GIT_A]]) + expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: GIT_A }) +}) + +it('updates a copy pnpm saved as a known host\'s shortcut to the HTTPS spec of another commit', async () => { + const { manager, dir, bundle } = await fixture() + const target = `git+https://github.com/o/plugins.git#${'b'.repeat(40)}&path:/packages/extra` + installedFromGit(dir, `github:o/plugins#${'a'.repeat(40)}&path:/packages/extra`) + gitPnpm(dir, bundle, { [target]: { name: 'extra', version: '1.1.0' } }) + expect(await manager.updateBundle('extra', '1.1.0', { spec: target })).toMatchObject({ step: 'install', result: { application: 'applied', version: '1.1.0' } }) +}) + +it('installs a Git target over the running version without HMR and reports the restart', async () => { + const { manager, dir, bundle, gitConnection } = await fixture('startup') + installedFromGit(dir) + const pnpm = gitPnpm(dir, bundle, { [GIT_B]: { name: 'extra', version: '1.1.0' } }) + expect(await manager.updateBundle('extra', '1.1.0', { spec: GIT_B })).toMatchObject({ + step: 'install', result: { application: 'restart-required', restartReason: 'no-hmr', version: '1.1.0' }, + }) + expect(pnpm.mock.calls.map(call => call[1])).toEqual([['add', GIT_B]]) + // Without HMR nothing is removed, so the repository is left to the installation. + expect(gitConnection).not.toHaveBeenCalled() +}) + +it('keeps the running Git copy without HMR when the target declares no bundle', async () => { + const { manager, dir, bundle } = await fixture('startup') + installedFromGit(dir) + const before = readFileSync(join(dir, 'package.json'), 'utf8') + gitPnpm(dir, bundle, { [GIT_B]: { name: 'extra', version: '1.1.0', bundle: false } }) + expect(await manager.updateBundle('extra', '1.1.0', { spec: GIT_B })).toMatchObject({ + step: 'install', result: { application: 'failed', error: { code: 'not-bundle' }, packageResult: { unmet: 'not-bundle' } }, + }) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) + +it.each(['network', 'timeout'] as const)('keeps a Git copy when a %s stops reaching the target repository before its removal', async (kind) => { + const env = { GIT_CONFIG_GLOBAL: '/application/git.config' } + const { manager, dir, bundle, gitConnection } = await fixture('live', false, undefined, {}, { command: 'pnpm', args: [], env }) + installedFromGit(dir) + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const pnpm = gitPnpm(dir, bundle, { [GIT_B]: { name: 'extra', version: '1.1.0' } }) + const failure = { exitCode: 128, output: 'fatal: unable to access', truncated: false, logPath: join(dir, 'git.log'), kind } + // The command line the check ran stays out of the answer. + gitConnection.mockResolvedValue({ ...failure, argv: ['git', 'ls-remote', '--', 'https://gitlab.example/g/plugins.git', 'HEAD'] }) + expect(await manager.updateBundle('extra', '1.1.0', { spec: GIT_B })).toEqual({ + from: '1.0.0', to: '1.1.0', step: 'check', + result: { + changed: false, application: 'failed', stage: 'install', target: 'extra', failedAt: 'spec-host', packageResult: failure, + error: { code: 'operation-error', diagnostic: 'fatal: unable to access' }, + }, + }) + expect(gitConnection).toHaveBeenCalledWith(expect.objectContaining({ kind: 'git', spec: GIT_B }), dir, expect.objectContaining({ timeoutMs: 5000, env })) + expect(pnpm).not.toHaveBeenCalled() + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) + +it('leaves a Git repository check that is not a connection failure to pnpm', async () => { + const { manager, dir, bundle, gitConnection } = await fixture() + installedFromGit(dir) + gitPnpm(dir, bundle, { [GIT_B]: { name: 'extra', version: '1.1.0' } }) + gitConnection.mockResolvedValue({ + exitCode: 127, output: 'spawn git ENOENT', truncated: false, logPath: join(dir, 'git.log'), kind: 'unknown', argv: ['git'], + }) + expect(await manager.updateBundle('extra', '1.1.0', { spec: GIT_B })).toMatchObject({ step: 'install', result: { application: 'applied' } }) +}) + +it('refuses an update whose target does not match where the copy was installed from, before changing anything', async () => { + const { manager, dir, bundle } = await fixture() + const view = viewAnswers(extraRegistry) + const pnpm = gitPnpm(dir, bundle, {}) + const refused = async (version: string, spec?: string) => (await manager.updateBundle('extra', version, spec === undefined ? {} : { spec })).result.error + expect(await refused('1.2.0', GIT_B)).toEqual({ code: 'invalid-spec', diagnostic: 'extra is installed from a registry and updates only to extra@1.2.0' }) + installedFromGit(dir) + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const pinnedOnly = { code: 'invalid-spec', diagnostic: 'extra is installed from Git and updates only to a Git spec pinned to a full commit' } + expect(await refused('1.1.0')).toEqual(pinnedOnly) + expect(await refused('1.1.0', 'extra@1.1.0')).toEqual(pinnedOnly) + expect(await refused('1.1.0', `${GIT_REPOSITORY}#main&path:/packages/extra`)).toEqual(pinnedOnly) + const sibling = `${GIT_REPOSITORY}#${'b'.repeat(40)}&path:/packages/other` + expect(await refused('1.1.0', sibling)).toEqual({ + code: 'invalid-spec', diagnostic: `${sibling} is not in the repository directory extra is installed from, gitlab.example/g/plugins#packages/extra`, + }) + expect(await refused('0.9.0', GIT_B)).toEqual({ code: 'invalid-spec', diagnostic: '0.9.0 is not a version newer than the installed 1.0.0' }) + expect(pnpm).not.toHaveBeenCalled() + expect(view).not.toHaveBeenCalled() + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index b6a87a6475..3f1a30d226 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -412,6 +412,102 @@ it.each([true, false])('rejects incompatible installed manifests before activati expect(outcome.output).toContain('and node_modules') }) +it.each([ + ['another package', 'other', '1.1.0'], + ['another version', 'extra', '1.0.5'], +] as const)('undoes a run that installs %s than the caller expects, with its modules', async (_name, name, version) => { + const { dir, context, pnpm } = fixture() + const manifestBefore = readFileSync(join(dir, 'package.json'), 'utf8') + writeFileSync(join(dir, 'pnpm-lock.yaml'), 'original-lock\n') + pnpm.mutate = (target) => { + installGuarded(target, name, name, version, '*') + writeFileSync(join(target, 'pnpm-lock.yaml'), 'changed-lock\n') + } + const outcome = await runProfilePnpm(context, ['add', `git+https://host.example/g/r.git#${'b'.repeat(40)}`], { + execution: 'service', outputBytes: 8192, activateNewBundles: false, expected: { name: 'extra', version: '1.1.0' }, + }) + expect(outcome.exitCode).toBe(1) + expect(outcome.incompatible).toBeUndefined() + expect(outcome.unmet).toBe('unexpected-package') + expect(outcome.output).toContain(`${name}@${version} is installed where extra@1.1.0 was expected`) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifestBefore) + expect(readFileSync(join(dir, 'pnpm-lock.yaml'), 'utf8')).toBe('original-lock\n') + expect(command.run).toHaveBeenLastCalledWith(expect.anything(), ['install', '--frozen-lockfile'], expect.anything()) +}) + +it('undoes a run that leaves the expected package at another version without changing it', async () => { + const { dir, context, pnpm } = fixture() + // Adding the saved spec again leaves the dependency as it was. + installGuarded(dir, 'extra', 'extra', '1.0.0', '*') + pnpm.mutate = () => {} + const outcome = await runProfilePnpm(context, ['add', 'extra@1'], { + execution: 'service', outputBytes: 8192, activateNewBundles: false, expected: { name: 'extra', version: '1.1.0' }, + }) + expect(outcome).toMatchObject({ exitCode: 1, unmet: 'unexpected-package' }) + expect(outcome.output).toContain('extra@1.0.0 is installed where extra@1.1.0 was expected') +}) + +it('undoes a run whose expected package declares no bundle, with its modules', async () => { + const { dir, context, pnpm } = fixture() + const manifestBefore = readFileSync(join(dir, 'package.json'), 'utf8') + writeFileSync(join(dir, 'pnpm-lock.yaml'), 'original-lock\n') + pnpm.mutate = (target) => { + install(target, 'extra') + writeFileSync(join(target, 'node_modules', 'extra', 'package.json'), JSON.stringify({ name: 'extra', version: '1.1.0' })) + } + const outcome = await runProfilePnpm(context, ['add', `git+https://host.example/g/r.git#${'b'.repeat(40)}`], { + execution: 'service', outputBytes: 8192, activateNewBundles: false, expected: { name: 'extra', version: '1.1.0' }, + }) + expect(outcome).toMatchObject({ exitCode: 1, unmet: 'not-bundle' }) + expect(outcome.output).toContain('extra@1.1.0 declares no bundle') + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifestBefore) + expect(command.run).toHaveBeenLastCalledWith(expect.anything(), ['install', '--frozen-lockfile'], expect.anything()) +}) + +it('undoes a run whose expected bundle patch cannot load, with its modules', async () => { + const { dir, context, pnpm } = fixture() + const manifestBefore = readFileSync(join(dir, 'package.json'), 'utf8') + writeFileSync(join(dir, 'pnpm-lock.yaml'), 'original-lock\n') + pnpm.mutate = (target) => { + install(target, 'extra') + writeFileSync(join(target, 'node_modules', 'extra', 'package.json'), JSON.stringify({ name: 'extra', version: '1.1.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(target, 'node_modules', 'extra', 'cordis.patch.yml'), 'insert: [\n') + } + const outcome = await runProfilePnpm(context, ['add', `git+https://host.example/g/r.git#${'b'.repeat(40)}`], { + execution: 'service', outputBytes: 8192, activateNewBundles: false, expected: { name: 'extra', version: '1.1.0' }, + }) + expect(outcome.exitCode).toBe(1) + expect(outcome.unmet).toBeUndefined() + expect(outcome.output).toContain('Cannot validate installed package extra') + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifestBefore) + expect(command.run).toHaveBeenLastCalledWith(expect.anything(), ['install', '--frozen-lockfile'], expect.anything()) +}) + +it('reports another package over a missing bundle declaration, and a run that installs nothing as unexpected', async () => { + const { context, pnpm } = fixture() + const options = { execution: 'service', outputBytes: 8192, activateNewBundles: false, expected: { name: 'extra', version: '1.1.0' } } as const + pnpm.mutate = (target) => { + install(target, 'other') + install(target, 'extra') + writeFileSync(join(target, 'node_modules', 'extra', 'package.json'), JSON.stringify({ name: 'extra', version: '1.1.0' })) + } + expect(await runProfilePnpm(context, ['add', 'extra@1.1.0'], options)).toMatchObject({ exitCode: 1, unmet: 'unexpected-package' }) + pnpm.mutate = () => {} + const empty = await runProfilePnpm(context, ['add', 'extra@1.1.0'], options) + expect(empty).toMatchObject({ exitCode: 1, unmet: 'unexpected-package' }) + expect(empty.output).toContain('extra@1.1.0 is not installed') +}) + +it('accepts a run that installs the package the caller expects', async () => { + const { context, pnpm } = fixture() + pnpm.mutate = (target) => { installGuarded(target, 'extra', 'extra', '1.1.0', '*') } + const outcome = await runProfilePnpm(context, ['add', `git+https://host.example/g/r.git#${'b'.repeat(40)}`], { + execution: 'service', outputBytes: 8192, activateNewBundles: false, expected: { name: 'extra', version: '1.1.0' }, + }) + expect(outcome).toMatchObject({ exitCode: 0 }) + expect(command.run).toHaveBeenCalledOnce() +}) + it('repairs a profile that had no lockfile without creating one', async () => { const { dir, context, pnpm } = fixture() pnpm.mutate = (target) => { diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index a9a5978899..ade243b05a 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -89,7 +89,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, updatable: false, rows: [], overrides: [], + installed: false, enabled: false, optional: false, removable: 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-market/README.i18n.yaml b/packages/boot/plugin-market/README.i18n.yaml index e8352139a5..2adde60540 100644 --- a/packages/boot/plugin-market/README.i18n.yaml +++ b/packages/boot/plugin-market/README.i18n.yaml @@ -21,8 +21,8 @@ en: b5d96ca29cf76f31 zh: e5099b4f694cf4c9 /deepseek-ai-dsh-plugin-market/understand-the-implementation: - en: 678705f57d986377 - zh: 68d090edfe04acde + en: cb56b9191fc680a1 + zh: eee0a1dfee9ea5bb /deepseek-ai-dsh-plugin-market/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -39,8 +39,8 @@ en: 02cf6033092eb236 zh: cb928bfa6d39a7f7 /deepseek-ai-dsh-plugin-market/known-limitations-and-deferred-work: - en: 54122c96969ebbc2 - zh: 3fbe84bc6069cb46 + en: a5f8aad8e45b333d + zh: bb84782c37db2574 /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 cdd9b211c2..255ea7fc2c 100644 --- a/packages/boot/plugin-market/README.md +++ b/packages/boot/plugin-market/README.md @@ -93,7 +93,7 @@ Concurrent reads of one URL share one fetch. A successful document is cached for Filtering and ordering read only the document; installation status and compatibility are computed for the returned page on every call. The first query with text in a document builds its text index with [MiniSearch](https://github.com/lucaong/minisearch), 500 plugins per event-loop turn, and later queries reuse it until the document is replaced. -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. +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. A version's `update` is true when it is newer than a copy `listBundles` marks `update: 'git'` and pins a commit of the repository and directory that copy pins, so the Plugins page can replace that copy with it through `pluginManager.updateBundle`; a registry copy's newer versions come from its registry instead. 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. @@ -122,7 +122,7 @@ Append-only: results enter the conversation as ordinary tool results and do not -- **No upgrade** — an installed bundle with a newer index version is reported as `update-available`; upgrading remains uninstall followed by install. +- **Git updates only** — only a copy installed from a Git commit updates to a newer index version, and only within the repository directory it came from; a copy installed from a registry updates from its registry, and one installed from another source, such as a local directory, a branch, or another repository, is uninstalled and installed again. - **Unsigned indexes** — index content, including a Git entry's name and version, is the index's claim; the installed manifest decides what is installed. - **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. diff --git a/packages/boot/plugin-market/README.zh.md b/packages/boot/plugin-market/README.zh.md index ca213854bd..0fdf245efe 100644 --- a/packages/boot/plugin-market/README.zh.md +++ b/packages/boot/plugin-market/README.zh.md @@ -94,7 +94,7 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒 过滤和排序只读取文档;安装状态和兼容性在每次调用时只为返回的页计算。一份文档上第一次带文本的查询使用 [MiniSearch](https://github.com/lucaong/minisearch) 构建其文本索引,每轮事件循环处理 500 个插件,之后的查询复用该索引,直到文档被替换。 -兼容性按版本计算:使用 `evaluatePluginCompatibility`,传入该版本的 `engines.dsh` 和 profile 的版本豁免,与安装时执行的检查相同。插件的默认版本是其不带先行版本号的最高兼容版本;插件完全没有此类版本时,使用其最高兼容的先行版本;没有兼容版本的插件没有默认版本。安装状态按包名根据 `pluginManager.listBundles()` 计算,以默认版本为准,没有默认版本时以最高版本为准:`not-installed`(包括当前 profile 尚未安装的按需目录条目);版本相同时为 `installed`;该版本更高时为 `update-available`;已安装版本未知、不是有效的 SemVer 或更高时为 `different-version`;DSH 安装随附的组合包(bundle)为 `provided`。 +兼容性按版本计算:使用 `evaluatePluginCompatibility`,传入该版本的 `engines.dsh` 和 profile 的版本豁免,与安装时执行的检查相同。插件的默认版本是其不带先行版本号的最高兼容版本;插件完全没有此类版本时,使用其最高兼容的先行版本;没有兼容版本的插件没有默认版本。安装状态按包名根据 `pluginManager.listBundles()` 计算,以默认版本为准,没有默认版本时以最高版本为准:`not-installed`(包括当前 profile 尚未安装的按需目录条目);版本相同时为 `installed`;该版本更高时为 `update-available`;已安装版本未知、不是有效的 SemVer 或更高时为 `different-version`;DSH 安装随附的组合包(bundle)为 `provided`。版本比 `listBundles` 标为 `update: 'git'` 的副本新,且固定到该副本所固定的仓库与目录中的一个提交时,其 `update` 为 true,插件页因此可以通过 `pluginManager.updateBundle` 用它替换该副本;注册表副本的新版本则来自其注册表。 条目详细介绍的读取共用服务拥有的一个读取器;文档选择和 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 并等待其退出。 @@ -123,7 +123,7 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒 -- **不支持升级**——索引版本更新的已安装组合包报告为 `update-available`;升级仍需先卸载再安装。 +- **只支持 Git 更新**——只有从 Git 提交安装的副本会更新到索引中较新的版本,且只在其来源的仓库目录内更新;从注册表安装的副本从其注册表更新,从其他来源(例如本地目录、分支或其他仓库)安装的副本需要先卸载再安装。 - **索引没有签名**——索引内容(包括 Git 条目的名称和版本)是索引的声明;实际安装什么由已安装的 manifest(元数据清单)决定。 - **后写入者覆盖**——`setIndexes` 会替换整个列表,因此两个窗口同时编辑索引源时,可能互相覆盖对方的修改。 - **拒绝重定向**——只能经重定向访问的索引必须以其最终 URL 配置。 diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index c737098a31..a2b1ea64d9 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market", "description": "Configured plugin market indexes for the Web Discover page and the plugin_market agent tool", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -44,10 +44,14 @@ "lib/worker.cjs", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { @@ -64,7 +68,7 @@ "hosted-git-info": "^9.0.3", "minisearch": "^7.2.0", "semver": "^7.8.5", - "zod": "^4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/boot/plugin-market/src/index.ts b/packages/boot/plugin-market/src/index.ts index b64d36e702..eb8a517da2 100644 --- a/packages/boot/plugin-market/src/index.ts +++ b/packages/boot/plugin-market/src/index.ts @@ -7,6 +7,8 @@ import semver from 'semver' import { evaluatePluginCompatibility, getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot' import { marketInstallTarget, type MarketIndex, type MarketPlugin, type MarketVersion } from '@deepseek-ai/dsh-plugin-market-index' import type { BundleInfo } from '@deepseek-ai/dsh-plugin-manager/types' +// Also declares the `pluginManager` service this one reads bundles from. +import { pinnedGitOrigin } from '@deepseek-ai/dsh-plugin-manager' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { @@ -19,9 +21,7 @@ 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`, the Loader's `loader/volatile-update` event, and the -// `connection` Fetch routes. -import type {} from '@deepseek-ai/dsh-plugin-manager' +// Type-only: `settings.update`, the Loader's `loader/volatile-update` event, and the `connection` Fetch routes. import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-client-connection' @@ -476,11 +476,16 @@ export default class PluginMarket extends TypertRemoteService { function listingOf( plugin: MarketPlugin, bundles: readonly BundleInfo[], exemptions: Readonly>, runtimeVersion: string, ): MarketListingEntry { - const versions = plugin.versions.map((version): MarketListingVersion => ({ - ...version, - install: marketInstallTarget(plugin.name, version), - compatibility: compatibilityOf(plugin.name, version, exemptions, runtimeVersion), - })) + const bundle = bundles.find(candidate => candidate.name === plugin.name) + // A Git copy updates only to another commit of the repository directory it came from. + const origin = bundle?.update === 'git' && bundle.source !== undefined ? pinnedGitOrigin(bundle.source) : undefined + const installedVersion = bundle?.version + const versions = plugin.versions.map((version): MarketListingVersion => { + const install = marketInstallTarget(plugin.name, version) + const update = origin !== undefined && installedVersion !== undefined && semver.gt(version.version, installedVersion) + && pinnedGitOrigin(install.spec) === origin + return { ...version, install, compatibility: compatibilityOf(plugin.name, version, exemptions, runtimeVersion), update } + }) const compatible = versions.filter(version => version.compatibility.compatible) const stable = plugin.versions.some(version => semver.prerelease(version.version) === null) const defaultVersion = compatible.find(version => !stable || semver.prerelease(version.version) === null)?.version @@ -490,12 +495,11 @@ function listingOf( ...plugin, versions, ...defaultVersion === undefined ? {} : { defaultVersion }, - installation: installationOf(plugin.name, measured, bundles), + installation: installationOf(bundle, measured), } } -function installationOf(name: string, version: string, bundles: readonly BundleInfo[]): MarketInstallation { - const bundle = bundles.find(candidate => candidate.name === name) +function installationOf(bundle: BundleInfo | undefined, version: string): MarketInstallation { // An on-demand catalog entry is listed before the profile installs it. if (bundle === undefined || (!bundle.installed && bundle.availability !== 'installation')) return { status: 'not-installed' } if (!bundle.installed) return { status: 'provided', ...bundle.version === undefined ? {} : { installedVersion: bundle.version } } diff --git a/packages/boot/plugin-market/src/types.ts b/packages/boot/plugin-market/src/types.ts index 4a4663ad77..fec4179c41 100644 --- a/packages/boot/plugin-market/src/types.ts +++ b/packages/boot/plugin-market/src/types.ts @@ -71,6 +71,12 @@ export type MarketCompatibility = export interface MarketListingVersion extends MarketVersion { readonly install: MarketInstallTarget readonly compatibility: MarketCompatibility + /** + * Whether `pluginManager.updateBundle` accepts this version for the installed copy: the copy came from a Git commit + * (`pluginManager` lists it with `update: 'git'`), this version is newer, and it pins a commit in the same repository + * and `path:` directory. `compatibility` reports separately whether the running DSH accepts it. + */ + readonly update: boolean } /** One index plugin with the profile-specific facts a market list displays. */ diff --git a/packages/boot/plugin-market/tests/market.spec.ts b/packages/boot/plugin-market/tests/market.spec.ts index 2bd2e998b5..0520e23d77 100644 --- a/packages/boot/plugin-market/tests/market.spec.ts +++ b/packages/boot/plugin-market/tests/market.spec.ts @@ -24,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, updatable: false, rows: [], overrides: [], ...fields } + return { name, official: false, availability: 'profile', enabled: true, installed: true, optional: false, removable: true, rows: [], overrides: [], ...fields } } function index(plugins: unknown[] = []): unknown { @@ -165,6 +165,35 @@ it('reports a provided bundle without a version', async () => { expect((await read(market, URL_A)).entries[0]?.installation).toEqual({ status: 'provided' }) }) +it('marks the versions newer than a Git copy, from the repository directory it pins, as updates', async () => { + const commit = (digit: string): string => digit.repeat(40) + const git = (version: string, digit: string, path = 'packages/p') => release(version, { source: { type: 'git', url: 'https://git.example/o/plugins', commit: commit(digit), path } }) + const versions = [git('1.3.0', 'd', 'packages/q'), git('1.2.0', 'c'), release('1.1.0'), git('1.0.0', 'a'), git('0.9.0', '9')] + serve(json(index(['from-git', 'from-registry', 'no-source', 'no-version', 'unpinned'].map(name => entry(name, {}, versions))))) + const pinned = `git+ssh://git@git.example/o/plugins.git#${commit('a')}&path:/packages/p` + const { market } = await mount({}, { + bundles: [ + bundle('from-git', { version: '1.0.0', update: 'git', source: pinned }), + bundle('from-registry', { version: '1.0.0', update: 'registry', source: 'from-registry@1.0.0' }), + bundle('no-source', { version: '1.0.0', update: 'git' }), + bundle('no-version', { update: 'git', source: pinned }), + bundle('unpinned', { version: '1.0.0', update: 'git', source: 'git+https://git.example/o/plugins.git#main&path:/packages/p' }), + ], + }) + const by = Object.fromEntries((await read(market, URL_A)).entries.map(item => [item.name, item])) + // The SSH form a profile saved has the HTTPS form's origin; another directory of the repository is another plugin. + expect(by['from-git']?.versions.map(version => [version.version, version.update])).toEqual([['1.3.0', false], ['1.2.0', true], ['1.1.0', false], ['1.0.0', false], ['0.9.0', false]]) + expect(by['from-git']?.versions[1]?.install).toEqual({ spec: `git+https://git.example/o/plugins.git#${commit('c')}&path:/packages/p` }) + for (const name of ['from-registry', 'no-source', 'no-version', 'unpinned']) expect(by[name]?.versions.some(version => version.update)).toBe(false) +}) + +it('matches a copy pnpm saved as a known host\'s shortcut with the index\'s HTTPS versions', async () => { + const commit = (digit: string): string => digit.repeat(40) + serve(json(index([entry('hosted', {}, [release('1.1.0', { source: { type: 'git', url: 'https://github.com/o/plugins', commit: commit('b'), path: 'p' } })])]))) + const { market } = await mount({}, { bundles: [bundle('hosted', { version: '1.0.0', update: 'git', source: `github:o/plugins#${commit('a')}&path:/p` })] }) + expect((await read(market, URL_A)).entries[0]?.versions[0]?.update).toBe(true) +}) + it('shares concurrent reads, caches documents, and refetches on refresh or expiry', async () => { vi.useFakeTimers() const release = Promise.withResolvers() diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index c5dbaea00a..e2e8fa0edd 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-browser-use", "description": "Exclusive named browser-use provider registration", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,7 +30,10 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index b10217beac..82bb315e7c 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -27,7 +27,10 @@ "locale/*.json", "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 8c6ebd500c..b475ce9b82 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0f971f7ac7c09139 zh: ceb866da681c709c /deepseek-ai-dsh-base/use-this-package/what-you-get: - en: 94430c891006526d - zh: 94ba771960ec6f71 + en: ab2fb279cdd726c5 + zh: ae84c61bddc00ddf /deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform: en: 2dce12f82d6ee955 zh: d4599eb899a3cd88 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index db77c345ec..5b5f163694 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -49,7 +49,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval and can reach loopback and private-network hosts. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. -Both `subagent` and `subagent_fork` create managed children and return a child id immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. +Both `subagent` and `subagent_fork` create managed children and return the child's name immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. The bundle also mounts [Agent Teams](../../team/agent-team/README.md) and its task tools. Team mode defaults off per root Session; the root conversation’s **Use Agent Teams** menu switch enables collaboration for that tree without changing the profile. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 53c04f4b52..62d6ad03b9 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -49,7 +49,7 @@ kind: "package-bundle" 开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,并且可以访问 loopback 与内网主机。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 -`subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回 child id。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 +`subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回子任务名称。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 此 bundle 还挂载 [Agent Teams](../../team/agent-team/README.zh.md) 及其任务工具。每个根 Session 的 Team 模式默认关闭;根会话菜单中的**使用智能体团队**开关为当前树开启协作,无需修改 profile。 diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 2565cbc7d5..cba883cd57 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 121a0feb5a..d47abd4ca9 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -33,7 +33,10 @@ "lib/startup.js", "cordis.patch.yml", "lib/json-stream-*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index 623f6546a0..6a9acc36cf 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /deepseek-ai-dsh-sdk-app/use-this-package: - en: 63aa871622cea3af - zh: 8f00a69640caadc5 + en: 544309ea0bdf6d79 + zh: 67f1f40c075d38b2 /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -24,8 +24,8 @@ en: 4f88e647c6df4f42 zh: 2bd3e4b0bdfe9412 /deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/what-the-model-sees: - en: 6b46520441d75a00 - zh: 04851fd123960847 + en: 2beeaec08b6e1a6d + zh: 9b98b0ae6be6c1bc /deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/token-effect: en: cef8efe97fe1ff16 zh: c052ab3e6706efa6 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index 60d1db572e..9eef06c769 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -37,7 +37,7 @@ The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_ The model can use [`plugin_manager`](../../boot/plugin-manager/README.md) in native and PTC modes. Every action, including listing, requires full access or approval for that call. The default workspace-write/ask policy needs a client approval responder: enable `approvalRequests` and handle `approval/request` through `harness.client.onRequest()`, or supply an approver in the composition. Without one, the operation is refused as unavailable. For Python package installation/removal, explicitly prepare Node and pnpm with `download_primary_runtime()` before starting the runtime; the packaged launcher then supplies them to plugin management without requiring system pnpm. Without a selected payload, package operations need `pnpm` on `PATH` or an explicit `plugin-manager.pnpmCommand`. See the [interactive approval example](../../../python/sdk/examples/manage_plugins.py). Saved profile changes report `restart-required` with `no-hmr`; the current runtime and its sessions retain their tools until the caller closes and starts the runtime again. Set `tool-plugin-manager.disabled: true` through a profile patch to hide this tool. The standalone `sdk-minimal` profile does not include it. -The Python runtime exposes explicitly downloaded authoring resources and Office sidecars to this profile. `DSH_PRIMARY_RUNTIME` overrides the authoring payload; an empty value disables its query. Office skills can use either resource independently. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence. See the [runtime package](../../../python/sdk-runtime/README.md) for downloads and configuration. +The Python runtime exposes explicitly downloaded authoring resources and Office sidecars to this profile. `DSH_PRIMARY_RUNTIME` overrides the authoring payload; an empty value disables its query. Office skills, the Office kit, and the [Office tools](../../document/tool-office/README.md) always load. The skills use their packaged assets; with an authoring payload they direct the model to its bundled Python environment, and without one to the user's existing Python. The tool reports an unavailable LibreOffice Kit until an Office sidecar is downloaded or selected. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence. See the [runtime package](../../../python/sdk-runtime/README.md) for downloads and configuration. ----- @@ -48,7 +48,7 @@ The Python runtime exposes explicitly downloaded authoring resources and Office #### What the model sees -The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, and adds the same model-interpolated coding persona as the Web and headless profiles. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`. The model also receives `present`, `ask_user_question`, `plugin_manager`, schedule tools, shared file-reference guidance, and time context. Child agents cannot use schedule tools. The [`time-context`](../../context/time-context/README.md) row accepts a fallback time zone. Skills use the configured resources described above. +The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, and adds the same model-interpolated coding persona as the Web and headless profiles. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`. The model also receives `present`, `ask_user_question`, `plugin_manager`, schedule tools, shared file-reference guidance, and time context. Child agents cannot use schedule tools. The [`time-context`](../../context/time-context/README.md) row accepts a fallback time zone. The skill catalog always lists the Office skills; their Python environment section names `load_workspace_dependencies` with an authoring payload and the user's existing Python without one. #### Token effect diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index eee5f3522c..2693d31def 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -37,7 +37,7 @@ SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_rep 模型可以在 native 和 PTC 模式下使用 [`plugin_manager`](../../boot/plugin-manager/README.zh.md)。每个操作(包括列举)都要求完整权限或本次调用的批准。默认 workspace-write/ask 策略需要客户端审批应答方:启用 `approvalRequests`,并通过 `harness.client.onRequest()` 处理 `approval/request`,或在组合中提供审批器。没有应答方时,操作因 unavailable 被拒绝。Python 安装/移除包时,先显式调用 `download_primary_runtime()` 准备 Node 和 pnpm,再启动运行时;打包启动器会将它们提供给插件管理,无需系统 pnpm。未选择载荷时,包操作仍要求 `PATH` 中有 `pnpm`,或显式配置 `plugin-manager.pnpmCommand`。可运行的接入方式见[交互审批示例](../../../python/sdk/examples/manage_plugins.py)。保存的 profile 变更返回 `restart-required` 和 `no-hmr`;当前运行时及其会话保留原有工具,直到调用方关闭并重新启动运行时。通过 profile patch 设置 `tool-plugin-manager.disabled: true` 可隐藏此工具。独立的 `sdk-minimal` profile 不包含它。 -Python 运行时向本 profile 提供显式下载的创作资源和 Office sidecar。`DSH_PRIMARY_RUNTIME` 覆盖创作载荷;空值禁用其查询。Office skills 可独立使用任一种资源。Profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先。下载与配置见[运行时包](../../../python/sdk-runtime/README.zh.md)。 +Python 运行时向本 profile 提供显式下载的创作资源和 Office sidecar。`DSH_PRIMARY_RUNTIME` 覆盖创作载荷;空值禁用其查询。Office skills、Office 套件和 [Office 工具](../../document/tool-office/README.zh.md)总是加载。skills 使用包内 assets;有创作载荷时指引模型使用其内置 Python 环境,没有时指引模型使用用户已有的 Python。下载或选择 Office sidecar 之前,工具报告 LibreOffice Kit 不可用。Profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先。下载与配置见[运行时包](../../../python/sdk-runtime/README.zh.md)。 ----- @@ -48,7 +48,7 @@ Python 运行时向本 profile 提供显式下载的创作资源和 Office sidec #### 模型看到什么 -profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,并添加与 Web 和 headless profile 相同、按当前模型插值的 coding persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。模型还会收到 `present`、`ask_user_question`、`plugin_manager`、定时任务工具、共用文件引用说明和时间上下文。子 agent 不能使用定时任务工具。[`time-context`](../../context/time-context/README.zh.md) 配置项接受回退时区。Skills 使用上述配置的资源。 +profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,并添加与 Web 和 headless profile 相同、按当前模型插值的 coding persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。模型还会收到 `present`、`ask_user_question`、`plugin_manager`、定时任务工具、共用文件引用说明和时间上下文。子 agent 不能使用定时任务工具。[`time-context`](../../context/time-context/README.zh.md) 配置项接受回退时区。技能目录总是列出 Office skills;有创作载荷时其 Python 环境说明指向 `load_workspace_dependencies`,没有时指向用户已有的 Python。 #### Token 影响 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 15a5245ade..e53d193129 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -26,22 +26,32 @@ - id: tool-plugin-manager disabled: false -# The packaged carrier supplies a default; DSH_PRIMARY_RUNTIME overrides it, -# including an empty-string opt-out. Each row can be patched independently. +# DSH_PRIMARY_RUNTIME names the primary runtime; the packaged launcher fills it +# from its download cache when unset, and an empty value opts out. Each row can +# be patched independently. Office skills and the Office tools always load, and +# the skills use their packaged assets. With a primary runtime the skills select +# `workspace-dependencies` and use its Python environment; without one they +# select `user` and direct the model to the user's Python. - insert: + - id: office-kit + name: '@deepseek-ai/dsh-office-kit' + config: + outputDirectory: !!js dshHomePath('office-kit', 'outputs') + + # The Office tools stay registered without Kit; calls report the service's unavailable error. + - id: tool-office + name: '@deepseek-ai/dsh-tool-office' + - id: workspace-dependencies name: '@deepseek-ai/dsh-tool-workspace-dependencies' - disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" + disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" config: - source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '')" + source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? '')" - id: skill-office name: '@deepseek-ai/dsh-skill-office' - disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI" config: - assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" - cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI" - node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" + pythonEnvironment: !!js "process.env.DSH_PRIMARY_RUNTIME ? 'workspace-dependencies' : 'user'" # Share native desktop tools and model context without starting a Web server. - insert: diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 2bb70ddf4e..c683538d68 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -27,7 +27,10 @@ "locale/*.json", "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { @@ -37,7 +40,9 @@ }, "dependencies": { "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-office-kit": "workspace:*", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", + "@deepseek-ai/dsh-tool-office": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0", "@deepseek-ai/dsh-skill-office": "workspace:*", diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index ed56dcb80a..262dc98be8 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index adb17d9e6d..7a16271db2 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -33,8 +33,8 @@ en: fdf38cb6e1b24464 zh: c14a8bcac9d4e5cd /deepseek-ai-dsh-web-app/understand-the-implementation: - en: 323742a00cfe67b2 - zh: eff5245a82fc16e7 + en: cc4f6d02a4d11d58 + zh: 276e0929502b4fca /deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics: en: a6d126f30b1594d7 zh: 7f0ccc8a55d6f743 @@ -65,6 +65,18 @@ /deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/kv-cache-effect: en: 37542a8b81a744f5 zh: c0048aaadecae296 +/deepseek-ai-dsh-web-app/model-experience/office-skills: + en: 8732b94114bef9e0 + zh: ab5f53bf541fae12 +/deepseek-ai-dsh-web-app/model-experience/office-skills/what-the-model-sees: + en: b6dc92ddf435f514 + zh: b907228a20afbd96 +/deepseek-ai-dsh-web-app/model-experience/office-skills/token-effect: + en: 9d9154d07ac52174 + zh: fcc6938b68025409 +/deepseek-ai-dsh-web-app/model-experience/office-skills/kv-cache-effect: + en: 43f28f4e120df7bb + zh: 3b4e0d384986b293 /deepseek-ai-dsh-web-app/known-limitations-and-deferred-work: en: 635c1336e2f4e821 zh: caabdecb02e2df14 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index b621d1e8c2..e2713f168b 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -100,7 +100,9 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
Implementation internals — click to expand -The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. +The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-kit` row mounts one lazy [Office conversion provider](../../document/office-kit/README.md) for Host consumers, including Desktop compositions using this bundle, and keeps converted files under `$DSH_HOME/office-kit/outputs`. The `standard` and `ptc` presets mount the [Office tools](../../document/tool-office/README.md) in every profile, whether or not Kit is installed, and declare them after an Office skill loads; the `minimal` preset omits them. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. + +The `skill-office` row mounts the [Office skills](../../skill/skill-office/README.md) from their packaged assets in every profile. Desktop with a nonempty `DSH_PRIMARY_RUNTIME` also mounts the bundled dependency-query row and selects `pythonEnvironment: 'workspace-dependencies'`; other profiles, and Desktop without a primary runtime, select `pythonEnvironment: 'user'` and leave the dependency-query row disabled. The [Desktop application](../../../apps/desktop/README.md#bundled-workspace-dependencies) owns resource packaging. ### Patch semantics @@ -162,6 +164,20 @@ Two managed-environment variable lines; constant per process. None; the plugin contributes no system-prompt text. +### Office skills + +#### What the model sees + +The skill catalog lists the Office router and three Office authoring skills in every profile. The loaded Python skills end with a Python environment section: on Desktop it directs the model to `load_workspace_dependencies`; elsewhere it directs the model to the project's or user's existing Python. + +#### Token effect + +The four catalog summaries in each initial catalog; loaded skill bodies only when the model requests them. + +#### KV Cache effect + +While resources and configuration stay unchanged, the built-in Office summaries and Python environment sections stay stable; the [Office skills](../../skill/skill-office/README.md#model-experience) describe when the catalog changes. + ## Known Limitations and Deferred Work diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 9df9111bf3..994400807e 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -100,7 +100,9 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-
实现细节——点击展开 -此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 +此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-kit` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-kit/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方,转换后的文件保存在 `$DSH_HOME/office-kit/outputs`。`standard` 和 `ptc` 预设在所有 profile 中挂载 [Office 工具](../../document/tool-office/README.zh.md),无论是否安装 Kit,并在加载 Office 技能后声明它们;`minimal` 预设不挂载它们。转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 + +`skill-office` 行在所有 profile 中从包内 assets 挂载 [Office 技能](../../skill/skill-office/README.zh.md)。`DSH_PRIMARY_RUNTIME` 非空的 Desktop 还挂载内置依赖查询行,并选择 `pythonEnvironment: 'workspace-dependencies'`;其他 profile 以及没有主运行时的 Desktop 选择 `pythonEnvironment: 'user'`,并禁用依赖查询行。资源打包由 [Desktop 应用](../../../apps/desktop/README.zh.md#bundled-workspace-dependencies)负责。 ### patch 语义 @@ -162,6 +164,20 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 无;该插件不贡献系统提示词文本。 +### Office 技能 + +#### 模型看到什么 + +所有 profile 的技能目录都列出 Office 路由和三个 Office 创作技能。加载的 Python 技能末尾带一节 Python 环境说明:Desktop 上指引模型调用 `load_workspace_dependencies`;其他 profile 指引模型使用项目或用户已有的 Python。 + +#### Token 影响 + +每个初始目录包含四条目录摘要;仅在模型请求时加载技能正文。 + +#### KV Cache 影响 + +资源和配置不变时,内置 Office 摘要和各 Python 环境说明保持稳定;目录何时变化见 [Office 技能](../../skill/skill-office/README.zh.md#model-experience)。 + ## 已知限制与延期工作 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index fb307f3e6a..59b3473ba5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -67,9 +67,9 @@ - id: session-log-download name: '@deepseek-ai/dsh-session-log-export' - # Session-header "Open In..." split button over the host application - # resolution (macOS/Windows/Linux; a host with no resolved application - # renders no button). Two halves: the host routes and the browser surface. + # Session-menu application choices over Host resolution on macOS, Windows, + # and Linux. An empty catalog hides the action. The Host routes and browser + # controls also serve the file-tree directory opener. - id: open-in-app name: '@deepseek-ai/dsh-host-open-in-app' config: @@ -288,8 +288,27 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' - - id: office-to-pdf - name: '@deepseek-ai/dsh-office-to-pdf' + - id: office-kit + name: '@deepseek-ai/dsh-office-kit' + config: + outputDirectory: !!js dshHomePath('office-kit', 'outputs') + + # Every profile uses the packaged Office skills. Desktop supplies their Python + # environment through the primary runtime named by DSH_PRIMARY_RUNTIME and + # selects `workspace-dependencies`. A Desktop profile launched without a + # nonempty value disables workspace-dependencies and, like the other + # profiles, selects `user` to direct the model to the user's Python. + - id: workspace-dependencies + name: '@deepseek-ai/dsh-tool-workspace-dependencies' + disabled: !!js "ctx.get('profileContext')?.name !== 'desktop' || !process.env.DSH_PRIMARY_RUNTIME" + config: + source: !!js process.env.DSH_PRIMARY_RUNTIME + root: !!js dshHomePath('dsh-runtimes', 'dsh-primary-runtime') + + - id: skill-office + name: '@deepseek-ai/dsh-skill-office' + config: + pythonEnvironment: !!js "ctx.get('profileContext')?.name === 'desktop' && process.env.DSH_PRIMARY_RUNTIME ? 'workspace-dependencies' : 'user'" # The right Sidebar's document tab: bounded file reads with selectable # Markdown, code, HTML, PDF, Office, and plain-text renderers. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index db79100589..dd4860c5c0 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, bash runtime variables, URL line)", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -40,7 +40,10 @@ "presets/minimal.patch.yml", "presets/cordis.patch.yml", "lib/public-url-*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { @@ -152,7 +155,7 @@ "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-mcp-manager": "workspace:*", "@deepseek-ai/dsh-message-feedback": "workspace:*", - "@deepseek-ai/dsh-office-to-pdf": "workspace:*", + "@deepseek-ai/dsh-office-kit": "workspace:*", "@deepseek-ai/dsh-persona": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", @@ -163,6 +166,7 @@ "@deepseek-ai/dsh-session-stats": "workspace:*", "@deepseek-ai/dsh-session-turn-outline": "workspace:*", "@deepseek-ai/dsh-skill-filesystem": "workspace:*", + "@deepseek-ai/dsh-skill-office": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-terminal": "workspace:*", "@deepseek-ai/dsh-terminal-bash": "workspace:*", @@ -176,6 +180,7 @@ "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-office": "workspace:*", "@deepseek-ai/dsh-tool-present": "workspace:*", "@deepseek-ai/dsh-tool-pwsh": "workspace:*", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", @@ -186,6 +191,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*", "@deepseek-ai/dsh-web-frontend": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", @@ -215,14 +221,23 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis-plugin-include": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", - "@deepseek-ai/libreoffice-kit": "^0.1.5", + "@deepseek-ai/libreoffice-kit": "0.2.3", "@deepseek-ai/dsh-otel": "workspace:*" } } diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index 678692075c..86ccc4f0ad 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -126,6 +126,8 @@ name: '@deepseek-ai/dsh-tool-executor-ptc' - id: present name: '@deepseek-ai/dsh-tool-present' + - id: tool-office + name: '@deepseek-ai/dsh-tool-office' - id: tool-plugin-manager name: '@deepseek-ai/dsh-plugin-manager/tools' disabled: true diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index e2e619fddd..a40aab5aa9 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -122,6 +122,8 @@ searchTimeoutMs: 60000 - id: present name: '@deepseek-ai/dsh-tool-present' + - id: tool-office + name: '@deepseek-ai/dsh-tool-office' - id: tool-plugin-manager name: '@deepseek-ai/dsh-plugin-manager/tools' disabled: !!js "!ctx.get('profileContext')" diff --git a/packages/bundle/web-app/tests/desktop-office.spec.ts b/packages/bundle/web-app/tests/desktop-office.spec.ts new file mode 100644 index 0000000000..d08ce29d2e --- /dev/null +++ b/packages/bundle/web-app/tests/desktop-office.spec.ts @@ -0,0 +1,211 @@ +/** Web application Office rows over a real Loader tree: skills, profile gating, Python, preset Office tools, and failure containment. */ + +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { dshHomePath } from '@deepseek-ai/dsh-home-paths' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import OfficeKit from '@deepseek-ai/dsh-office-kit' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import { resolveKitModule } from '@deepseek-ai/dsh-office-kit/src/locate.ts' +import SkillRegistry from '@deepseek-ai/dsh-skill' +import * as SkillOffice from '@deepseek-ai/dsh-skill-office' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as ToolOffice from '@deepseek-ai/dsh-tool-office' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import * as WorkspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' + +// Kit presence is the deployment fact under test; every other row runs unmodified. +vi.mock('@deepseek-ai/dsh-office-kit/src/locate.ts', async (original) => { + const actual = await original() + return { ...actual, resolveKitModule: vi.fn(() => ({ kind: 'missing', error: new Error('Cannot find package') })) } +}) + +const assets = fileURLToPath(new URL('../../../skill/skill-office/assets/', import.meta.url)) +const names = ['office', 'office-docx', 'office-pptx', 'office-xlsx'] +const ROWS = ['office-kit', 'tool-office', 'workspace-dependencies', 'skill-office'] +const OFFICE_TOOLS = ['office_convert', 'office_recalculate', 'office_render'] + +afterEach(() => { vi.unstubAllEnvs() }) + +/** Plugin rows of one shipped agent preset. */ +function presetPlugins(preset: string): { id: string; name: string }[] { + const [row] = loadOverlayPatches(preset, fileURLToPath(new URL(`../presets/${preset}.patch.yml`, import.meta.url))) + .flatMap(patch => patch.insert ?? []) + return (row!.config as { plugins: { id: string; name: string }[] }).plugins +} + +it('mounts the Office tools in the standard and PTC presets and omits it from the minimal preset', () => { + const office = (preset: string) => presetPlugins(preset).filter(row => row.id === 'tool-office').map(row => row.name) + expect(office('standard')).toEqual(['@deepseek-ai/dsh-tool-office']) + expect(office('ptc')).toEqual(['@deepseek-ai/dsh-tool-office']) + expect(office('minimal')).toEqual([]) + const host = loadOverlayPatches('desktop-office', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []).map(row => row.id) + expect(host).not.toContain('tool-office') +}) + +async function boot(profile: string, primaryRuntime: (root: string) => string | undefined, mounted: readonly string[] = ROWS) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-desktop-office-rows-'))) + onTestFinished(() => rm(root, { recursive: true, force: true })) + vi.stubEnv('DSH_HOME', join(root, 'home')) + vi.stubEnv('DSH_PRIMARY_RUNTIME', primaryRuntime(root)) + const nodePath = join(root, 'primary-runtime', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node') + await mkdir(dirname(nodePath), { recursive: true }) + await writeFile(nodePath, '') + const workspace = join(root, 'workspace') + await mkdir(workspace) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('profileContext', { + name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: join(root, 'package.json'), + cwd: workspace, home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, + }) + ctx.provide('dshHomePath', dshHomePath) + provideWorkingDirectoryFixture(ctx, workspace) + await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) + await ctx.plugin(SkillRegistry) + await ctx.plugin(LocalFileSystem, { cwd: workspace }) + ctx.baseUrl = 'file:///' + await ctx.plugin(Loader).await() + ctx.loader.builtins.dependencies = WorkspaceDependencies + ctx.loader.builtins.office = SkillOffice + ctx.loader.builtins.officeKit = OfficeKit + ctx.loader.builtins.toolOffice = ToolOffice + const builtin: Record = { + 'office-kit': 'cordis:officeKit', 'tool-office': 'cordis:toolOffice', 'workspace-dependencies': 'cordis:dependencies', 'skill-office': 'cordis:office', + } + const hostRows = loadOverlayPatches('desktop-office', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []).filter(row => ROWS.includes(row.id)) + const rows = [...hostRows.slice(0, 1), presetPlugins('standard').find(row => row.id === 'tool-office')!, ...hostRows.slice(1)] + expect(rows.map(row => row.id)).toEqual(ROWS) + const entries = rows.map(row => ({ ...row, name: builtin[row.id]! })) + await ctx.loader.root.update(entries.filter(row => mounted.includes(row.id))) + await ctx.loader.await() + return { ctx, root, workspace, entries } +} + +/** Convert through `office_convert` as an Agent whose previous request declared the Office tools. */ +async function convertReport(ctx: Context, workspace: string, callId: string) { + const id = SessionId(callId) + const session = Session.create(id, [], { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd: workspace, isSeeded: false }) + vi.spyOn(session, 'requestHeader').mockReturnValue({ config: { provider: 'fixture', model: 'fixture' }, tools: ctx.tools.schemas() }) + const agent = { session, options: {} } as never + const signal = new AbortController().signal + const toolSet = await ctx.tools.resolve({ agent, modelSelection: { provider: 'fixture', model: 'fixture' }, signal }) + try { + return await executeToolFixture(ctx, { + signal, callId: ToolCallId(callId), name: 'office_convert', agent, toolSet, arguments: { input: 'report.docx', format: 'pdf' }, + }) + } finally { toolSet.dispose() } +} + +it('mounts packaged Desktop skills with the primary runtime Python beside the Office tools', async () => { + const { ctx, root } = await boot('desktop', root => join(root, 'primary-runtime')) + expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({ pythonEnvironment: 'workspace-dependencies' }) + expect(ctx.loader.resolve('workspace-dependencies').fiber?.effectiveConfig).toEqual({ + source: join(root, 'primary-runtime'), root: join(root, 'home', 'dsh-runtimes', 'dsh-primary-runtime'), + }) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names) + expect((await ctx.skills.get('office-docx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-docx').split(sep).join('/') }) + expect(ctx.tools.schemas().map(tool => tool.name).sort()).toEqual(['load_workspace_dependencies', ...OFFICE_TOOLS]) + expect(ctx.officeKit.outputDirectory).toBe(join(root, 'home', 'office-kit', 'outputs')) + expect((await ctx.skills.get('office-docx'))?.content).toMatch(/## Python environment\n\n.*`load_workspace_dependencies`/u) + expect((await ctx.skills.get('office'))?.content).toContain('`office_convert`') +}) + +it('keeps the Office tools and returns the unavailable Kit error to the model when Kit is absent', async () => { + const { ctx, workspace } = await boot('web', () => undefined) + await writeFile(join(workspace, 'report.docx'), 'docx bytes') + const warnings: string[] = [] + ctx.logger.exporter({ levels: { default: 2 }, export: ({ type, args }) => { if (type === 'warn') warnings.push(String(args[0])) } }) + const result = await convertReport(ctx, workspace, 'office-missing-kit') + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: `Error: cannot convert "${join(workspace, 'report.docx')}": LibreOffice Kit is not installed. ` + + 'This deployment does not provide LibreOffice Kit; install or enable it as this deployment requires, then retry.' + + ' Tell the user this operation is unavailable, or retry later if the cause may be temporary; do not substitute system soffice, npx, or another converter.' }]) + expect(resolveKitModule).toHaveBeenCalled() + expect(warnings.filter(warning => warning.startsWith('OfficeKitError: LibreOffice Kit is not installed.'))).toHaveLength(1) +}) + +it('mounts the preset Office tools without the Office kit row and reaches the service after the row loads', async () => { + const { ctx, workspace, entries } = await boot('web', () => undefined, ROWS.filter(id => id !== 'office-kit')) + vi.mocked(resolveKitModule).mockClear() + await writeFile(join(workspace, 'report.docx'), 'docx bytes') + await ctx.loader.resolve('tool-office').fiber!.await() + expect(ctx.tools.schemas().map(tool => tool.name).sort()).toEqual(OFFICE_TOOLS) + const convert = () => convertReport(ctx, workspace, 'office-without-service') + expect((await convert()).content).toEqual([{ type: 'text', text: 'Error: cannot convert "report.docx": The document converter is unavailable.' + + ' Tell the user this operation is unavailable, or retry later if the cause may be temporary; do not substitute system soffice, npx, or another converter.' }]) + expect(resolveKitModule).not.toHaveBeenCalled() + await ctx.loader.root.update(entries) + await ctx.loader.await() + expect((await convert()).content).toEqual([{ type: 'text', text: expect.stringContaining('LibreOffice Kit is not installed.') as string }]) + expect(resolveKitModule).toHaveBeenCalled() +}) + +it('mounts Office skills with packaged defaults and the user Python outside the Desktop profile without launcher variables', async () => { + const { ctx } = await boot('web', () => undefined) + expect(ctx.loader.resolve('skill-office').disabled).toBe(false) + expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({ pythonEnvironment: 'user' }) + expect(ctx.loader.resolve('workspace-dependencies').disabled).toBe(true) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names) + expect((await ctx.skills.get('office-docx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-docx').split(sep).join('/') }) + const docx = (await ctx.skills.get('office-docx'))?.content + expect(docx).toMatch(/## Python environment\n\n.*`python3` or `python` on PATH/u) + expect(docx).not.toContain('load_workspace_dependencies') + expect(ctx.tools.schemas().map(tool => tool.name).sort()).toEqual(OFFICE_TOOLS) +}) + +it('mounts packaged Office skills with the user Python in a Desktop profile launched without a primary runtime', async () => { + const { ctx } = await boot('desktop', () => undefined) + // Both rows evaluate their expressions without a TypeError and validate their configuration. + await ctx.loader.resolve('skill-office').fiber!.await() + expect(ctx.loader.resolve('workspace-dependencies').disabled).toBe(true) + expect(ctx.loader.resolve('workspace-dependencies').fiber).toBeUndefined() + expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({ pythonEnvironment: 'user' }) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names) + expect((await ctx.skills.get('office-docx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-docx').split(sep).join('/') }) + const docx = (await ctx.skills.get('office-docx'))?.content + expect(docx).toMatch(/## Python environment\n\n.*`python3` or `python` on PATH/u) + expect(docx).not.toContain('load_workspace_dependencies') + expect(ctx.tools.schemas().map(tool => tool.name).sort()).toEqual(OFFICE_TOOLS) +}) + +it('treats an empty primary runtime variable as unset in the Desktop profile', async () => { + const { ctx } = await boot('desktop', () => '') + await ctx.loader.resolve('skill-office').fiber!.await() + expect(ctx.loader.resolve('workspace-dependencies').disabled).toBe(true) + expect(ctx.loader.resolve('skill-office').fiber?.effectiveConfig).toStrictEqual({ pythonEnvironment: 'user' }) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names) +}) + +it('fails only the dependency-query row when the carrier supplies an unusable primary runtime path', async () => { + const { ctx } = await boot('desktop', () => 'primary-runtime') + await expect(ctx.loader.resolve('workspace-dependencies').fiber!.await()).rejects.toThrow('absolute') + await ctx.loader.resolve('skill-office').fiber!.await() + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names) + expect(ctx.tools.schemas().map(tool => tool.name).sort()).toEqual(OFFICE_TOOLS) +}) + +it('removes the Office tools when their row is disposed', async () => { + const { ctx } = await boot('web', () => undefined) + for (const name of OFFICE_TOOLS) expect(ctx.tools.get(name)).toBeDefined() + await ctx.loader.resolve('tool-office').fiber!.dispose() + for (const name of OFFICE_TOOLS) expect(ctx.tools.get(name)).toBeUndefined() +}) diff --git a/packages/bundle/web-app/tests/document-conversion.e2e.ts b/packages/bundle/web-app/tests/document-conversion.e2e.ts index 0291b4dea4..c45922dc8b 100644 --- a/packages/bundle/web-app/tests/document-conversion.e2e.ts +++ b/packages/bundle/web-app/tests/document-conversion.e2e.ts @@ -7,22 +7,26 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' -import { OfficeSourceKey, type OfficeToPdfRequest } from '@deepseek-ai/dsh-office-to-pdf' -import * as LibreOfficeProvider from '@deepseek-ai/dsh-office-to-pdf' +import { OfficeSourceKey, type OfficeKitRequest } from '@deepseek-ai/dsh-office-kit' +import * as LibreOfficeProvider from '@deepseek-ai/dsh-office-kit' import { expect, it, onTestFinished } from 'vitest' it('loads one shared conversion row and retains caller-owned PDFs after disposal', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-conversion-loader-')) + const outputDirectory = await mkdtemp(join(tmpdir(), 'dsh-conversion-outputs-')) const ctx = new Context() onTestFinished(async () => { try { await ctx.fiber.dispose() } - finally { await rm(directory, { recursive: true, force: true }) } + finally { + await rm(directory, { recursive: true, force: true }) + await rm(outputDirectory, { recursive: true, force: true }) + } }) const rows = loadOverlayPatches('conversion-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) .flatMap(patch => patch.insert ?? []) - .filter(row => row.name === '@deepseek-ai/dsh-office-to-pdf') - expect(rows.map(row => row.id)).toEqual(['office-to-pdf']) - const configured = applyEntryPatches(rows, [{ id: 'office-to-pdf', config: { maxConcurrentConversions: 1 } }], + .filter(row => row.name === '@deepseek-ai/dsh-office-kit') + expect(rows.map(row => row.id)).toEqual(['office-kit']) + const configured = applyEntryPatches(rows, [{ id: 'office-kit', config: { maxConcurrentConversions: 1, outputDirectory } }], (message) => { throw new Error(message) }) const configPath = join(directory, 'cordis.yml') await writeFile(configPath, JSON.stringify(configured)) @@ -33,19 +37,19 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal ctx.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-office-to-pdf') throw new Error(`Unexpected plugin: ${specifier}`) + if (specifier !== '@deepseek-ai/dsh-office-kit') throw new Error(`Unexpected plugin: ${specifier}`) return LibreOfficeProvider }, } as unknown as NonNullable await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await ctx.loader.await() - const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-to-pdf')! + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-kit')! await entry.fiber!.await() expect(ctx.get('workspaceFiles')).toBeUndefined() expect(ctx.get('skills')).toBeUndefined() const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url)) let reads = 0 - const request = (key: string): OfficeToPdfRequest => ({ + const request = (key: string): OfficeKitRequest => ({ extension: 'docx', priority: 'foreground', source: { key: OfficeSourceKey(key), version: 'fixture', bytes: bytes.length, async read(signal, maxBytes) { @@ -56,7 +60,8 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal }, }, }) - const provider = ctx.officeToPdf + const provider = ctx.officeKit + expect(provider.outputDirectory).toBe(outputDirectory) const [first, second] = await Promise.all([provider.convert(request('first')), provider.convert(request('second'))]) expect(reads).toBe(2) expect(Buffer.from(first.pdf).subarray(0, 5).toString()).toBe('%PDF-') diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index 9af5ca6a4f..53def4cde2 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files' -import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf' +import OfficeKit from '@deepseek-ai/dsh-office-kit' import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -28,23 +28,28 @@ vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) it('loads the shipped Office rows with separately patched settings and authorized PDF output', async () => { const directory = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-office-'))) + const outputDirectory = await mkdtemp(join(tmpdir(), 'dsh-web-office-outputs-')) const ctx = new Context() onTestFinished(async () => { try { await ctx.fiber.dispose() } - finally { vi.restoreAllMocks(); await rm(directory, { recursive: true, force: true }) } + finally { + vi.restoreAllMocks() + await rm(directory, { recursive: true, force: true }) + await rm(outputDirectory, { recursive: true, force: true }) + } }) const configPath = join(directory, 'cordis.yml') const expectedRows = { - 'office-to-pdf': '@deepseek-ai/dsh-office-to-pdf', + 'office-kit': '@deepseek-ai/dsh-office-kit', 'ui-sidebar-documentpreview': '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', } const rows = loadOverlayPatches('web-office-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) .flatMap(patch => patch.insert ?? []).filter(row => row.id !== undefined && Object.hasOwn(expectedRows, row.id)) expect(rows.map(row => [row.id, row.name])).toEqual(Object.entries(expectedRows)) - const providerConfig = { maxInputBytes: 4096, maxConcurrentConversions: 1, fontFallbacks: [['Missing Serif', 'Available Serif']] } + const providerConfig = { maxInputBytes: 4096, maxConcurrentConversions: 1, fontFallbacks: [['Missing Serif', 'Available Serif']], outputDirectory } const clientConfig = DocumentPreview.Config({ office: { maxCachedEntries: 3, maxCachedBytes: 8192 } }) const configured = applyEntryPatches(rows, [ - { id: 'office-to-pdf', config: providerConfig }, + { id: 'office-kit', config: providerConfig }, { id: 'ui-sidebar-documentpreview', config: clientConfig }, ], (message) => { throw new Error(message) }) expect(configured.find(row => row.id === 'ui-sidebar-documentpreview')!.config).toEqual(clientConfig) @@ -80,7 +85,7 @@ it('loads the shipped Office rows with separately patched settings and authorize ['@deepseek-ai/dsh-fs-local', LocalFileSystem], ['@deepseek-ai/dsh-typert-registry', TypertRegistry], ['@deepseek-ai/dsh-api-workspace-files', WorkspaceFiles], - ['@deepseek-ai/dsh-office-to-pdf', OfficeToPdf], + ['@deepseek-ai/dsh-office-kit', OfficeKit], ['@deepseek-ai/dsh-client-ui-sidebar-documentpreview', DocumentPreview], ]) ctx.loader.internal = { @@ -94,6 +99,7 @@ it('loads the shipped Office rows with separately patched settings and authorize await ctx.loader.await() const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry])) for (const row of configured) await entries.get(row.id)!.fiber!.await() + expect(ctx.officeKit.outputDirectory).toBe(outputDirectory) const injections: IndexInjection[] = [] ctx.emit('webserver/index-inject', injections, PAGE_PEER) expect(injections).toEqual([ @@ -111,25 +117,25 @@ it('loads the shipped Office rows with separately patched settings and authorize const signal = new AbortController().signal const version = (await ctx.workspaceFiles.stat(scope, 'report.docx', signal)).version const before = session.seq - const result = await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal) + const result = await ctx.officeKit.render(scope, 'report.docx', 'foreground', signal) expect(result).toEqual({ absolutePath: sourcePath, version, offset: 0, eof: true, bytes: pdf.length, - data: Uint8Array.from(pdf), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation }) + data: Uint8Array.from(pdf), missingFonts: ['Missing Serif'], generation: ctx.officeKit.generation }) const readAgain = vi.spyOn(ctx.fs, 'readBytes') - expect(await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).toEqual(result) + expect(await ctx.officeKit.render(scope, 'report.docx', 'foreground', signal)).toEqual(result) expect(readAgain).not.toHaveBeenCalled() expect(await readFile(sourcePath, 'utf8')).toBe('authorized OOXML') expect(session.seq).toBe(before) expect(ctx.get('agents')).toBeUndefined() - const { maxConcurrentConversions: _count, ...kitOptions } = providerConfig + const { maxConcurrentConversions: _count, outputDirectory: _outputs, ...kitOptions } = providerConfig expect(kit.create).toHaveBeenCalledWith(expect.objectContaining(kitOptions)) - await expect(ctx.officeToPdf.render(scope, 'missing.docx', 'foreground', signal)) + await expect(ctx.officeKit.render(scope, 'missing.docx', 'foreground', signal)) .rejects.toMatchObject({ code: 'workspace-file/not-found' }) const refusal = new FsError('read refused', 'FS_SANDBOX_DENIED') vi.spyOn(ctx.fs, 'stat').mockRejectedValueOnce(refusal) - await expect(ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal) + await expect(ctx.officeKit.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal) if (process.platform !== 'win32') { await symlink(sourcePath, join(directory, 'link.docx')) - await expect(ctx.officeToPdf.render(scope, 'link.docx', 'foreground', signal)) + await expect(ctx.officeKit.render(scope, 'link.docx', 'foreground', signal)) .rejects.toMatchObject({ code: 'workspace-file/not-regular-file' }) } expect(render).toHaveBeenCalledOnce() diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index c241804058..d2dd72b601 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport and generation lifecycle", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -39,14 +39,17 @@ "dependencies": { "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "files": [ "locale/*.json", "lib/index.js", "lib/client.browser.js", "lib/client.node.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 469d7fca07..ef19d8c3c5 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -58,13 +58,17 @@ "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "dependencies": { - "zod": "4.4.3", + "zod": "4.6.6", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index e65b6b8b30..d92121c57e 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Web client graph synchronization and rebuilt-bundle reload transport", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -53,6 +53,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index f1d983c979..3e21127719 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -63,7 +63,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "scripts": { "bundle": "tsdown", diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 82d1743a16..a30a066c12 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -49,7 +49,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json index f076c28603..7baf25596d 100644 --- a/packages/client/product-analytics/package.json +++ b/packages/client/product-analytics/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-product-analytics", "description": "Desktop product event collection and authenticated Host reporting", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -73,14 +73,18 @@ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" } } diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index c371a6fccd..c554b2a178 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -54,6 +54,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json index 6b0e6ecfff..c4fe99087f 100644 --- a/packages/client/shortcuts/package.json +++ b/packages/client/shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-shortcuts", "description": "Application keyboard command registry and physical-key routing", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -60,7 +60,10 @@ "lib/index.js", "lib/client.js", "lib/protocol.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "dependencies": { "@deepseek-ai/dsh-util-crypto": "workspace:*", diff --git a/packages/client/store/package.json b/packages/client/store/package.json index a7101a61f2..64e7f1c38c 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -32,6 +32,9 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 8895b1dce9..7df6b6f529 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -239,7 +239,10 @@ interface ClientBundleOptions { readonly companions?: readonly UserConfig[] /** Overrides for the package's primary Node-side library config. */ readonly lib?: UserConfig - /** Optional legal or attribution text selected by emitted client filename. */ + /** + * Optional legal or attribution text selected by emitted client filename. The preset prepends it after + * minification, so its comment lines reach the artifact byte for byte. + */ readonly clientBanner?: (fileName: string) => string | undefined } @@ -797,6 +800,12 @@ function clientConfig( // Plugin code is fetched outside Vite's module graph, so its own bundle // must carry the TS/TSX mapping consumed by browser profiling tools. sourcemap: true, + // Compress the browser bundle's text while identifier names stay readable + // and the source map still resolves through the published `src`. The + // compressor stays off: the browser worker image lowers `yield*` only as a + // whole statement, and a compressor that folds statements into expressions + // hides it. + minify: { compress: false, mangle: false }, clean: false, deps: { neverBundle: isRequested, @@ -929,10 +938,10 @@ function clientConfig( isolation.sourcePath(source, mapPath) return browserSourcePath(source, mapPath) }, - banner: (chunk) => { - const registration = `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {` + banner: chunk => `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {`, + postBanner: (chunk) => { const prefix = clientBanner?.(chunk.fileName) - return prefix === undefined ? registration : `${prefix}\n${registration}` + return prefix === undefined ? '' : `${prefix}\n` }, footer: 'return module.exports; } });', intro: 'var module = { exports: {} }; var exports = module.exports;', diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index aa97a7b60d..d60ad24bef 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -75,7 +75,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "dependencies": {} } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 96af208257..9d37272647 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -68,11 +68,10 @@ } /* The default preset reads as selected exactly like the Appearance cubes: - platform fill + the bluish-400 border (static token — the bluish-400 step - has no alias-layer name). */ + platform fill + the selected border. */ .cardActive { background: var(--dsw-alias-bg-module-platform); - border-color: var(--dsw-static-neutral-bluish-400); + border-color: var(--dsw-alias-border-selected); } /* Mode selection is off, not the preset itself: mute only the selectable diff --git a/packages/client/ui-agent-team/README.i18n.yaml b/packages/client/ui-agent-team/README.i18n.yaml index dfab72bf26..3f894b097c 100644 --- a/packages/client/ui-agent-team/README.i18n.yaml +++ b/packages/client/ui-agent-team/README.i18n.yaml @@ -18,8 +18,8 @@ en: d502ab38773eef34 zh: e1f9c4edbc74c38f /deepseek-ai-dsh-client-ui-agent-team/use-this-package/inspect-the-task-board: - en: 7e88fb00a3abd230 - zh: 08efcf80afecb0c0 + en: 2f606827b3ab14bc + zh: 7f713d10268e7498 /deepseek-ai-dsh-client-ui-agent-team/understand-the-implementation: en: 9b0b6a8505a9ebbf zh: beb5d89f80463b3e diff --git a/packages/client/ui-agent-team/README.md b/packages/client/ui-agent-team/README.md index d156e7b7cb..7e87010dcb 100644 --- a/packages/client/ui-agent-team/README.md +++ b/packages/client/ui-agent-team/README.md @@ -37,7 +37,7 @@ The [Web bundle](../../bundle/web-app/README.md) loads this package by default. Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The noncollapsible Shared tasks header uses a Checklist icon and completed, in-progress, and pending counts, with zero counts omitted and wording and separators matching the input task list. Compact read-only rows show the subject, status, and a chevron, with 13px primary titles and hover geometry aligned to subagent rows; expanded details use 12px text. Task rows use 7px vertical padding, 6px horizontal padding, and 7px gaps to align their clickable content with subagent rows. All task details start closed; opening one closes the previous task. Details show only the full description when present, followed by a single-line owner label and Session id in the same color with a 4px gap. Long owner ids are ellipsized and reveal their full text in a tooltip only when clipped. Task-text and owner tooltips appear after 500ms of hover. Every owner is identified by its Session id. The shared menu is 360px wide with 6px outer padding, constrained to fit the viewport. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; task lists and details have no nested scrolling. Agents mutate tasks through tools; the panel provides no mutation controls. A projection failure appears separately above any retained tasks and remains visible when no tasks exist. +The noncollapsible Shared tasks header uses a Checklist icon and completed, in-progress, and pending counts, with zero counts omitted and wording and separators matching the input task list. Compact read-only rows show the subject, status, and a chevron, with 13px primary titles and hover geometry aligned to subagent rows; expanded details use 12px text. Task rows use 7px vertical padding, 6px horizontal padding, and 7px gaps to align their clickable content with subagent rows. All task details start closed; opening one closes the previous task. Details show only the full description when present, followed by a single-line owner label and owner name in the same color with a 4px gap. Long owner names are ellipsized and reveal their full text in a tooltip only when clipped. Task-text and owner tooltips appear after 500ms of hover. Owners display the task's saved `ownerName`, such as `lead` or `reviewer/checker`, independently of loaded subagent catalogs. A task without `ownerName` displays its `ownerId`; a task without either field displays the localized unowned label. The shared menu is 360px wide with 6px outer padding, constrained to fit the viewport. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; task lists and details have no nested scrolling. Agents mutate tasks through tools; the panel provides no mutation controls. A projection failure appears separately above any retained tasks and remains visible when no tasks exist. ----- diff --git a/packages/client/ui-agent-team/README.zh.md b/packages/client/ui-agent-team/README.zh.md index 6a370c6eeb..c0db84fe78 100644 --- a/packages/client/ui-agent-team/README.zh.md +++ b/packages/client/ui-agent-team/README.zh.md @@ -37,7 +37,7 @@ Chat 隐藏历史 `/team on` 和 `/team off` 记录中尚未完成或执行成 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -不可折叠的“共享任务”标题使用 Checklist 图标,并显示已完成、进行中和待处理数量;省略零值,文案和分隔符与输入区任务列表一致。紧凑只读行显示主题、状态和展开箭头,13px 主标题与悬停区域对齐子智能体行;展开详情使用 12px 文字。任务行使用 7px 垂直内边距、6px 水平内边距和 7px 间距,使可点击内容与 subagent 行对齐。所有任务详情初始关闭,打开一项会关闭上一项。详情仅显示完整描述(如有),下方以单行显示负责人标签和 Session id,两者使用相同颜色,间距为 4px。过长的负责人 id以省略号截断,仅在被截断时通过工具提示显示完整内容。任务文字和负责人的工具提示在悬停 500ms 后显示。每个负责人均通过其 Session id 标识。共享菜单宽度为 360px,外层内边距为 6px,并受视口可用宽度约束。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,任务列表与详情不单独滚动。Agent 通过工具修改任务;面板不提供修改控件。投影失败以独立警告显示在保留的任务上方,即使没有任务也保持可见。 +不可折叠的“共享任务”标题使用 Checklist 图标,并显示已完成、进行中和待处理数量;省略零值,文案和分隔符与输入区任务列表一致。紧凑只读行显示主题、状态和展开箭头,13px 主标题与悬停区域对齐子智能体行;展开详情使用 12px 文字。任务行使用 7px 垂直内边距、6px 水平内边距和 7px 间距,使可点击内容与 subagent 行对齐。所有任务详情初始关闭,打开一项会关闭上一项。详情仅显示完整描述(如有),下方以单行显示负责人标签和负责人名称,两者使用相同颜色,间距为 4px。过长的负责人名称以省略号截断,仅在被截断时通过工具提示显示完整内容。任务文字和负责人的工具提示在悬停 500ms 后显示。负责人显示任务保存的 `ownerName`,例如 `lead` 或 `reviewer/checker`,不依赖已加载的 subagent 目录。缺少 `ownerName` 时显示 `ownerId`;两个字段都没有时显示本地化的无负责人标签。共享菜单宽度为 360px,外层内边距为 6px,并受视口可用宽度约束。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,任务列表与详情不单独滚动。Agent 通过工具修改任务;面板不提供修改控件。投影失败以独立警告显示在保留的任务上方,即使没有任务也保持可见。 ----- diff --git a/packages/client/ui-agent-team/package.json b/packages/client/ui-agent-team/package.json index 12887a765d..32fb78c0ec 100644 --- a/packages/client/ui-agent-team/package.json +++ b/packages/client/ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-team", "description": "Web shared task board for a subagent tree", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -75,6 +75,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-agent-team/src/client/TeamBoard.tsx b/packages/client/ui-agent-team/src/client/TeamBoard.tsx index dbc554d33c..d6d0538fa9 100644 --- a/packages/client/ui-agent-team/src/client/TeamBoard.tsx +++ b/packages/client/ui-agent-team/src/client/TeamBoard.tsx @@ -43,7 +43,7 @@ function TaskRow({ task, rootId, expanded, onToggle, t }: Pick void }) { - const owner = task.ownerId ?? t('unowned') + const owner = task.ownerName ?? task.ownerId ?? t('unowned') const blocked = task.status === 'pending' && !task.ready const detailsId = `team-task-${rootId}-${task.id}` return ( diff --git a/packages/client/ui-agent-team/tests/team-board.client.spec.tsx b/packages/client/ui-agent-team/tests/team-board.client.spec.tsx index b09aedc4e5..f60eb99e4a 100644 --- a/packages/client/ui-agent-team/tests/team-board.client.spec.tsx +++ b/packages/client/ui-agent-team/tests/team-board.client.spec.tsx @@ -30,6 +30,7 @@ const task: TeamTask = { description: 'Build the Team runtime', status: 'in_progress', ownerId: SESSION, + ownerName: 'lead', blockedBy: [], writeScopes: ['src'], ready: false, @@ -131,7 +132,7 @@ describe('TeamBoard', () => { const description = 'First line\n'.repeat(30) + 'Final description line' const b = bench({ value: { ...team, tasks: [ { ...task, subject: longSubject, description }, - { ...task, id: TASK_2, subject: 'Review implementation', ownerId: WORKER }, + { ...task, id: TASK_2, subject: 'Review implementation', ownerId: WORKER, ownerName: 'worker' }, ] } }) render() const first = row(longSubject) @@ -141,8 +142,8 @@ describe('TeamBoard', () => { const details = document.getElementById(first.getAttribute('aria-controls')!)! const owner = details.querySelector('[data-team-task-owner]')! expect(owner.tagName).toBe('P') - expect(owner.textContent).toBe(`${zh.owner}: ${SESSION}`) - expect(ownerValue()).toBe(SESSION) + expect(owner.textContent).toBe(`${zh.owner}: lead`) + expect(ownerValue()).toBe('lead') const paragraphs = details.querySelectorAll('p') expect(paragraphs).toHaveLength(2) expect(paragraphs[0]?.textContent).toBe(description) @@ -154,7 +155,7 @@ describe('TeamBoard', () => { expect(screen.getAllByRole('button')).toHaveLength(2) fireEvent.click(row('Review implementation')) expect(first.getAttribute('aria-expanded')).toBe('false') - expect(ownerValue()).toBe(WORKER) + expect(ownerValue()).toBe('worker') expect(document.querySelectorAll('[data-team-task-details]')).toHaveLength(1) expect(screen.queryByText(description)).toBeNull() fireEvent.click(row('Review implementation')) @@ -179,12 +180,12 @@ describe('TeamBoard', () => { expect(row('Completed second').querySelector('[data-state="done"]')).toBeTruthy() fireEvent.click(row('Completed second')) expect(row('Completed second').getAttribute('aria-expanded')).toBe('true') - expect(ownerValue()).toBe(SESSION) + expect(ownerValue()).toBe('lead') expect(screen.getByText(task.description)).toBeTruthy() }) it('announces blocked status without displaying readiness, dependencies, scopes, warnings, or task ids', () => { - const { ownerId: _ownerId, ...unowned } = task + const { ownerId: _ownerId, ownerName: _ownerName, ...unowned } = task const b = bench({ value: { ...team, tasks: [ { ...unowned, status: 'pending', blockedBy: [TASK_2, 'missing' as TeamTaskId] }, { ...task, id: TASK_2, subject: 'Prepare shared types', status: 'completed' }, @@ -212,40 +213,31 @@ describe('TeamBoard', () => { fireEvent.click(row()) expect(within(row()).getByRole('img', { name: zh['status.pending'] })).toBeTruthy() expect(row().querySelector('[data-state="idle"]')).toBeTruthy() - expect(ownerValue()).toBe(SESSION) + expect(ownerValue()).toBe('lead') expect(document.querySelector('[data-team-task-details]')?.children).toHaveLength(1) expect(document.querySelector('[data-team-task-details] p:not([data-team-task-owner])')).toBeNull() }) - it('uses the owner id independently of catalog descriptions and Session titles', () => { - const b = bench({ - byId: { [SESSION]: summary(SESSION), [NESTED]: { ...summary(NESTED, 'Session title'), parentId: WORKER } }, - projections: { - [SESSION]: { state: 'ready', error: null, values: { - agentTeam: { ...team, tasks: [{ ...task, ownerId: NESTED }] }, - } }, - [WORKER]: { state: 'ready', error: null, values: { - subagentCatalog: [{ id: NESTED, createdAt: 0, mode: 'continuable', label: 'Catalog responsibility' }], - } }, - }, - }) + it('renders a saved owner path without loading its parent catalog', () => { + const b = bench({ value: { ...team, tasks: [{ ...task, ownerId: NESTED, ownerName: 'worker/checker' }] } }) render() fireEvent.click(row()) - expect(ownerValue()).toBe(NESTED) - setProjectionSnapshot(b.sessions, WORKER, { state: 'ready', error: null, values: {} }) + expect(ownerValue()).toBe('worker/checker') act(() => { b.sessions.update((draft) => { draft.byId[NESTED] = { ...summary(NESTED), displayTitle: 'Display title' } }) }) - expect(ownerValue()).toBe(NESTED) - setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, ownerId: WORKER }] }) - expect(ownerValue()).toBe(WORKER) + expect(ownerValue()).toBe('worker/checker') }) - it('uses owner id when neither root nor parent projections exist', () => { - const b = bench({ value: { ...team, tasks: [{ ...task, ownerId: NESTED }] }, byId: { - [NESTED]: { ...summary(NESTED), parentId: WORKER }, + it('uses the owner Session ID when the saved task has no name', () => { + const { ownerName: _ownerName, ...historical } = task + const b = bench({ value: { ...team, tasks: [{ ...historical, ownerId: WORKER }] }, projections: { + [SESSION]: { state: 'ready', error: null, values: { + agentTeam: { ...team, tasks: [{ ...historical, ownerId: WORKER }] }, + subagentCatalog: [{ id: WORKER, createdAt: 0, mode: 'continuable', label: 'Worker', name: 'worker' }], + } }, } }) render() fireEvent.click(row()) - expect(ownerValue()).toBe(NESTED) + expect(ownerValue()).toBe(WORKER) }) it('hides retained tasks while Team is disabled and shows them when enabled', () => { @@ -344,8 +336,8 @@ describe('TeamBoard', () => { render() expect(screen.getByRole('heading').textContent).toBe(`${en.tasks}${makeTranslate(en)('progress.active', { count: 1 })}`) fireEvent.click(row()) - expect(ownerValue()).toBe(SESSION) - expect(document.querySelector('[data-team-task-owner]')?.textContent).toBe(`${en.owner}: ${SESSION}`) + expect(ownerValue()).toBe('lead') + expect(document.querySelector('[data-team-task-owner]')?.textContent).toBe(`${en.owner}: lead`) expect(screen.queryByText(TASK_1)).toBeNull() expect(screen.queryByText('Show more')).toBeNull() expect(screen.queryByText('Show less')).toBeNull() @@ -355,8 +347,9 @@ describe('TeamBoard', () => { vi.useFakeTimers() enableTooltips() const ownerId = '8c0404a0-4c67-48ab-9513-50b4a755d6de' as SessionId + const { ownerName: _ownerName, ...historical } = task const b = bench({ - value: { ...team, tasks: [{ ...task, ownerId }] }, + value: { ...team, tasks: [{ ...historical, ownerId }] }, }) const view = render() fireEvent.click(row()) diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index fa8ff9cbc3..d0889ea071 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -68,6 +68,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 50a75b6f8c..f9484bfcb2 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -68,7 +68,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/client/ui-attachment/src/FileCard.module.css b/packages/client/ui-attachment/src/FileCard.module.css index 739ed5f8f0..01b434da97 100644 --- a/packages/client/ui-attachment/src/FileCard.module.css +++ b/packages/client/ui-attachment/src/FileCard.module.css @@ -6,7 +6,7 @@ width: 240px; height: 64px; padding: 0 12px; - border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg); background: var(--dsw-specific-input-major, transparent); box-sizing: border-box; @@ -14,7 +14,7 @@ } .failed { - border-color: var(--dsw-alias-state-error-primary, #d54941); + border-color: var(--dsw-alias-state-error-primary); } .icon { @@ -72,13 +72,13 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 15px; } .metaFailed { - color: var(--dsw-alias-state-error-primary, #d54941); + color: var(--dsw-alias-state-error-primary); } .remove { @@ -94,8 +94,8 @@ border: none; border-radius: 50%; corner-shape: round; - background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.72)); - color: var(--dsw-alias-label-primary-inverted, #fff); + background: var(--dsw-alias-button-contrast-fill); + color: var(--dsw-alias-label-primary-inverted); opacity: 0; cursor: pointer; transition: opacity 0.2s ease-in-out; @@ -134,8 +134,8 @@ } .removeFailed { - background: var(--dsw-alias-state-error-primary, #d54941); - color: #fff; + background: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-label-on-color); opacity: 1; } @@ -147,7 +147,7 @@ overflow: hidden; height: 2px; border-radius: 1px; - background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); + background: var(--dsw-specific-file-upload-track); } .progressBar { @@ -155,7 +155,7 @@ width: 35%; height: 100%; border-radius: inherit; - background: var(--dsw-alias-brand-primary, #4d6bfe); + background: var(--dsw-alias-brand-primary); animation: file-card-progress 1.2s ease-in-out infinite alternate; } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index 2d2dc878f6..6531ed284b 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -31,7 +31,7 @@ } .thumbnailFailed { - border-color: var(--dsw-alias-state-error-primary, #d54941); + border-color: var(--dsw-alias-state-error-primary); cursor: pointer; } @@ -48,7 +48,7 @@ inset: 0; display: grid; place-items: center; - color: var(--dsw-alias-state-error-primary, #d54941); + color: var(--dsw-alias-state-error-primary); } .progressTrack { @@ -59,7 +59,7 @@ overflow: hidden; height: 2px; border-radius: 1px; - background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); + background: var(--dsw-specific-file-upload-track); pointer-events: none; } @@ -68,7 +68,7 @@ width: 35%; height: 100%; border-radius: inherit; - background: var(--dsw-alias-brand-primary, #4d6bfe); + background: var(--dsw-alias-brand-primary); animation: image-upload-progress 1.2s ease-in-out infinite alternate; } diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 0d88d7f266..b4f23db76e 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand artwork and click-triggered home background", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -61,6 +61,9 @@ "lib/index.js", "lib/client.js", "lib/assets", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index bbed78bfaf..3120b6b576 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -30,8 +30,8 @@ en: 382821ca3ffa60dd zh: 9840ffa07c13082a /deepseek-ai-dsh-client-ui-chat/turn-process-folding: - en: b0d806604bc17006 - zh: 18dabe5dc2d0d419 + en: 7cc6692b879aa500 + zh: e924733c88b526c9 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: en: b1630d93a52f483e zh: 59cb81ca50ecffc4 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index a430e12e74..d845c15bbd 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -98,6 +98,8 @@ The completed-turn action footer follows the recorded Turn end. Its action row s ## Turn Process Folding +Empty `user/message` records with `source.kind: 'retry'` remain in Session history while Chat renders a compact continuation-request receipt instead of a user bubble. Ordinary empty user messages and retry messages containing content retain their normal presentation. + During uninterrupted following, local transcript and steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending steering follows Inbox order across clients, using matching local echoes in place. Admitted local steering also suppresses matching stale Inbox rows until the claim projection arrives; steering without a locally tracked submission continues to follow the Inbox projection. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble. When Chat ends with an open Turn control and that Turn has no visible input, the first local transcript echo precedes the control. Other echoes remain at the flow tail. The control and echoes share one keyed list, so arrival of the control preserves the echo's mounted identity. Durable inputs replace their matching echoes in the same render. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index ecf93eed36..80402eadf2 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -98,6 +98,8 @@ Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目 ## 轮次过程折叠 +带 `source.kind: 'retry'` 的空 `user/message` 记录保留在 Session 历史中,Chat 显示轻量的继续请求记录,不生成用户气泡。普通空用户消息与包含内容的重试消息保持正常展示。 + 正常在线时,本地 transcript 与 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理 steering 遵循 Inbox 顺序,在匹配位置使用本地回显。已入档的本地 steering 还会排除匹配的旧 Inbox 行,直到领取投影到达;没有本地提交身份的 steering 仍按 Inbox 投影显示。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。 Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第一条本地 transcript 回显显示在控制行前。其他回显保留在正文末尾。控制行与回显共用一个 keyed 列表,因此控制行到达时不会重新挂载回显。持久输入在同一次渲染中替换匹配的回显。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index dd85d72096..18667520c5 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -109,6 +109,9 @@ "lib/index.js", "lib/client.js", "lib/assets", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 5e0e99b0d3..9af20c8051 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -321,7 +321,7 @@ width: 240px; min-height: 64px; padding: 8px 12px; - border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg); background: var(--dsw-specific-input-major, transparent); box-sizing: border-box; @@ -354,7 +354,7 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 15px; } diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css index 4bf309274c..0d964a6790 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css @@ -26,6 +26,17 @@ .header:hover { color: var(--dsw-alias-label-secondary); } } .icon { display: inline-flex; flex: none; color: inherit; } +.continuation { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + padding: 2px 0; + color: var(--dsw-alias-label-tertiary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); + overflow-wrap: anywhere; +} .title { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--dsh-content-font-size, 14px); } .time { flex: none; margin-left: auto; color: inherit; font: var(--dsw-font-xxs-12); } .chevron, .openChevron { flex: none; color: inherit; } diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx index e6e366666a..70b4ded7bc 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx @@ -1,10 +1,10 @@ -/** An independent, expandable notice explaining a non-human Turn trigger. */ +/** Recorded Turn triggers and compact manual-continuation receipts. */ import { useId, useState, type ComponentType } from 'react' import { IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular, IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular, - IconUsersOutlineRegular, type IconProps, + IconUsersOutlineRegular, IconPlayOutlineRegular, type IconProps, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps } from '../contract/slots.ts' import { formatMessageClock } from './message-chrome.ts' @@ -13,6 +13,7 @@ import { turnTriggerDetails, type TurnTriggerIcon } from './turn-trigger.ts' import css from './TurnTriggerNodeView.module.css' const TRIGGER_ICONS: Record> = { + continue: IconPlayOutlineRegular, request: IconContextInjectionOutlineRegular, goal: IconGoalOutlineRegular, agent: IconPaperPlaneOutlineRegular, @@ -31,6 +32,12 @@ export function TurnTriggerNodeView({ node, t }: Pick + + {t(details.title)} + + ) const date = new Date(node.data.time) const time = formatMessageClock(node.data.time, t) return ( diff --git a/packages/client/ui-chat/src/client/chat/turn-trigger.ts b/packages/client/ui-chat/src/client/chat/turn-trigger.ts index ade8093490..f1e5a2397e 100644 --- a/packages/client/ui-chat/src/client/chat/turn-trigger.ts +++ b/packages/client/ui-chat/src/client/chat/turn-trigger.ts @@ -3,7 +3,7 @@ import type { ContextMessageNode } from '../contract/snapshot.ts' import type { ChatKey } from '../locale.ts' /** Existing primitive glyph selected for a Turn trigger's source family. */ -export type TurnTriggerIcon = 'agent' | 'github' | 'goal' | 'job' | 'plugin' | 'request' | 'schedule' | 'subagent' | 'team' | 'webhook' +export type TurnTriggerIcon = 'agent' | 'continue' | 'github' | 'goal' | 'job' | 'plugin' | 'request' | 'schedule' | 'subagent' | 'team' | 'webhook' function record(value: unknown): Record { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : {} @@ -30,17 +30,21 @@ function subagentTitle(reason: unknown): ChatKey { /** * Describe a waking message using its recorded source. * @param node - durable context, including the original notification body. - * @returns localized title key and source-family icon. + * @returns localized title, source-family icon, and compact presentation for continuation receipts. */ export function turnTriggerDetails(node: ContextMessageNode): { title: ChatKey icon: TurnTriggerIcon + compact?: true } { const source = record(node.source) const kind = field(source, 'kind') let title: ChatKey = 'message.trigger.request' let icon: TurnTriggerIcon = 'request' switch (kind) { + case 'retry': + if (node.content.length === 0) return { title: 'message.continuation.requested', icon: 'continue', compact: true } + break case 'goal': { title = 'message.trigger.goal' icon = 'goal' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index fb7684a8f7..9120812197 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -38,7 +38,7 @@ declare module '../contract/chat-nodes.ts' { steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode - /** Non-human input that starts a Turn. */ + /** Recorded notification or manual continuation that starts a Turn. */ 'turn-trigger': ContextMessageNode } } @@ -136,12 +136,16 @@ export const messageDefinition: ConversationNodeDefinition = { update: context => context.state, buildViewNode: (context) => { if (context.state === undefined) return null + const location = contextLocation(context) + if (context.state.kind === 'context' && context.start?.event.type === 'user/message' + && context.start.event.data.source.kind === 'retry' && context.start.event.data.content.length === 0) { + return chatNode(context, 'turn-trigger', context.state.seq, context.state, { location, groupTree: 'input' }) + } const waking = context.state.kind === 'context' && context.start?.event.type === 'user/message' && context.state.waking === true // Human input claimed for Step 1 opens an exchange; Notice-woken Turns and // later steering stay in the current exchange, so they never fold earlier Turns. - const location = contextLocation(context) const human = context.state.kind === 'user' || context.state.kind === 'steering' const opensExchange = human && location.kind === 'step' && location.step.step === 1 return chatNode( diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index c21cbdad59..0b2bc2151d 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -171,6 +171,7 @@ export const zh = { 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', + 'message.continuation.requested': '已请求继续生成', 'message.retry.scheduled': '等待重试模型请求', 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', 'message.retry.delay': '重试延迟:', @@ -380,6 +381,7 @@ export const en = { 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', + 'message.continuation.requested': 'Requested to continue generation', 'message.retry.scheduled': 'Waiting to retry model request', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Retry delay: ', @@ -586,6 +588,7 @@ export const ja = { 'message.retry.active': 'モデルリクエストを再試行中', 'message.retry.cancelled': 'モデルリクエストの再試行をキャンセルしました', 'message.retry.started': 'モデルリクエストを再試行しました', + 'message.continuation.requested': '生成の続行をリクエストしました', 'message.retry.scheduled': 'モデルリクエストの再試行待ちです', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}秒', 'message.retry.delay': '再試行までの待機時間: ', @@ -792,6 +795,7 @@ export const es = { 'message.retry.active': 'Reintentando solicitud de modelo', 'message.retry.cancelled': 'Reintento de solicitud de modelo cancelado', 'message.retry.started': 'Solicitud de modelo reintentada', + 'message.continuation.requested': 'Se ha solicitado continuar la generación', 'message.retry.scheduled': 'Esperando para volver a intentar la solicitud de modelo', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Retraso de reintento: ', @@ -998,6 +1002,7 @@ export const fr = { 'message.retry.active': 'Nouvelle tentative de demande de modèle', 'message.retry.cancelled': 'Nouvelle tentative de demande de modèle annulée', 'message.retry.started': 'Demande de modèle réessayée', + 'message.continuation.requested': 'Suite de la génération demandée', 'message.retry.scheduled': 'En attente de réessayer la demande de modèle', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}', 'message.retry.delay': 'Délai de nouvelle tentative : ', @@ -1204,6 +1209,7 @@ export const de = { 'message.retry.active': 'Modellanforderung wird erneut versucht', 'message.retry.cancelled': 'Wiederholungsversuch der Modellanforderung abgebrochen', 'message.retry.started': 'Modellanfrage erneut versucht', + 'message.continuation.requested': 'Fortsetzung der Generierung angefordert', 'message.retry.scheduled': 'Warten auf erneuten Versuch der Modellanforderung', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Wiederholungsverzögerung: ', @@ -1410,6 +1416,7 @@ export const ru = { 'message.retry.active': 'Повторный запрос модели', 'message.retry.cancelled': 'Повтор запроса модели отменен.', 'message.retry.started': 'Повторный запрос модели', + 'message.continuation.requested': 'Запрошено продолжение генерации', 'message.retry.scheduled': 'Ожидание повторного запроса модели', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Задержка повтора: ', @@ -1616,6 +1623,7 @@ export const pt = { 'message.retry.active': 'Tentando novamente a solicitação de modelo', 'message.retry.cancelled': 'Nova tentativa de solicitação de modelo cancelada', 'message.retry.started': 'Solicitação de modelo repetida', + 'message.continuation.requested': 'Continuação da geração solicitada', 'message.retry.scheduled': 'Aguardando para tentar novamente a solicitação de modelo', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Atraso na nova tentativa: ', diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 0151781d5b..57ed47cdcf 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -626,6 +626,39 @@ describe('built-in conversation node Definitions', () => { }, ) + it.each(['replay', 'live', 'prepend'] as const)('presents empty retry receipts through %s while retaining other input', (mode) => { + const retry = { id: 'empty-retry', role: 'user', content: [], source: { kind: 'retry' } } + const before = [ + at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [retry] }), + at(2, 'turn/start', { turn: 1 }), + at(3, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(4, 'step/start', { turn: 1, step: 1 }), + ] + const message = at(5, 'user/message', retry, { surfaceOp: 'append' }) + const value = assembler(mode === 'replay' ? [...before, message] : mode === 'prepend' ? [message] : [], mode === 'prepend') + if (mode === 'live') for (const entry of [...before, message]) value.append(entry) + if (mode === 'prepend') value.prepend(before, false) + value.flush() + const current = snapshot(value) + const receipt = node(current, 'turn-trigger') + expect(receipt).toMatchObject({ anchorSeq: 5, groupTree: 'input', data: { content: [], source: { kind: 'retry' } } }) + expect(current.order).toContain(receipt?.key) + expect(node(current, 'unknown')).toBeUndefined() + + value.append(at(6, 'user/message', { ...retry, id: 'retry-with-content', + content: [{ type: 'text', text: 'retained context' }], + }, { surfaceOp: 'append' })) + value.append(at(7, 'user/message', { ...retry, id: 'ordinary-empty', source: { kind: 'user' } }, { surfaceOp: 'append' })) + value.append(at(8, 'developer/message', { turn: 1, step: 1, + message: { ...retry, id: 'developer-empty', role: 'developer' }, + }, { surfaceOp: 'append' })) + value.flush() + expect(node(snapshot(value), 'context')?.data).toMatchObject({ content: [{ type: 'text', text: 'retained context' }] }) + expect(node(snapshot(value), 'user')?.data).toMatchObject({ content: [], source: { kind: 'user' } }) + expect(snapshot(value).nodes.values().find(candidate => candidate.anchorSeq === 8)?.data) + .toMatchObject({ content: [], source: { kind: 'retry' } }) + }) + it.each(['replay', 'live', 'prepend'] as const)('classifies waking Inbox messages through %s', (mode) => { const notice = { ...textMessage('notice', 'scheduled work'), source: { kind: 'schedule' } } const human = textMessage('human', 'user task') diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx index bbb2999cc7..e12ca7973e 100644 --- a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx @@ -24,6 +24,20 @@ function trigger(source: unknown): ChatNode<'turn-trigger'> { } describe('Turn trigger notices', () => { + it.each([ + [en, 'Requested to continue generation'], + [zh, '已请求继续生成'], + ])('shows an empty retry as a compact continuation receipt', (locale, title) => { + const node = trigger({ kind: 'retry' }) + node.data.content = [] + expect(turnTriggerDetails(node.data)).toEqual({ title: 'message.continuation.requested', icon: 'continue', compact: true }) + const view = render() + expect(view.getByText(title)).toBeDefined() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.querySelector('[data-generation-continuation] svg')).not.toBeNull() + expect(view.container.querySelector('time')).toBeNull() + }) + it.each([ [{ kind: 'schedule' }, 'schedule', 'Automation task'], [{ kind: 'tool-jobs' }, 'job', 'Background task updated'], @@ -35,6 +49,7 @@ describe('Turn trigger notices', () => { [{ kind: 'webhook', provider: 'github' }, 'github', 'GitHub event received'], [{ kind: 'webhook', provider: 'custom' }, 'webhook', 'External event received'], [{ kind: 'custom-extension' }, 'request', 'Execution requested'], + [{ kind: 'retry' }, 'request', 'Execution requested'], ] as const)('presents the recorded %j source', (source, icon, title) => { const node = trigger(source) const details = turnTriggerDetails(node.data) diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index b4c671ae23..be40237c4a 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -69,6 +69,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml index 1f54beccbb..da161da095 100644 --- a/packages/client/ui-context-menu/README.i18n.yaml +++ b/packages/client/ui-context-menu/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 725b47765ee8f30b /deepseek-ai-dsh-client-ui-context-menu/use-this-package: - en: c84c65c7d24d51c3 - zh: 98c8f40a326a8f0b + en: 892cfb46cfcddcc0 + zh: c534b44e7ef53522 /deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions: en: 11bea09005de2678 zh: d56e25d2d93022aa diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md index dff6ea78cc..704d8f5043 100644 --- a/packages/client/ui-context-menu/README.md +++ b/packages/client/ui-context-menu/README.md @@ -29,6 +29,8 @@ The Web application bundle mounts this Client plugin. External Client plugins de The plugin injects `useContextMenu` into every Slot scope through `ctx.slots.provideRoot`. Call `const { getTriggerProps } = useContextMenu()` once in the component and spread `getTriggerProps(target, handlers?)` onto each trigger. The factory is an ordinary function and can run in loops. It handles right-click, Shift+F10, the ContextMenu key, viewport coordinates, and focus without scrolling. Existing handlers run first; `preventDefault()` keeps their ownership and prevents duplicate handling by composed or nested bindings. Repeated, composing, and unrelated key events remain untouched. Pass `undefined` to retain native behavior, or a target resolver to read business state when the gesture begins. The resolved object is captured before focus changes. Plain children receive event props or a factory through props or an existing delegate; no additional React Context is needed. Special editors and canvases can call `ctx.contextMenus.open()` directly. +Use `renderFactorySlot('contextMenu.button', { target, label, className?, size? })` for an ellipsis trigger. `label` is localized by the caller; `size: 'sm'` selects the compact toolbar button with a label tooltip, while omission retains caller-styled row geometry without a tooltip. The factory owns focus, pointer and click isolation, the bottom-edge anchor, and expanded state. Each rendered button toggles only the popup it opened: activating it again closes that popup even after the button moves or its target's display fields change, while another button for an equal target opens its own popup. Repeated right-click and keyboard gestures keep the 4-pixel rule. Target families need no identity rule here. It hides only when the target kind has no registered contribution, without resolving actions early. The stable `ctx.contextMenus.registeredKinds` observable publishes registration and withdrawal immediately, including providers activated after the button mounts. A registered provider returning no actions still makes its button available. + ### Register actions Call `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))` in the contributing plugin. `resolve` receives the captured target and its cancellation signal; return localized actions with stable IDs, optional icons, disabled state, submenus, and execution callbacks. Contributions sort by `order`, then ID. Duplicate contribution IDs within one target family fail registration; duplicate action paths reject that provider’s result. IDs are encoded by segment before publication. diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md index 78f154feae..1fc68c3a1e 100644 --- a/packages/client/ui-context-menu/README.zh.md +++ b/packages/client/ui-context-menu/README.zh.md @@ -29,6 +29,8 @@ Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.clien 插件通过 `ctx.slots.provideRoot` 向每个 Slot 作用域注入 `useContextMenu`。组件调用一次 `const { getTriggerProps } = useContextMenu()`,再将 `getTriggerProps(target, handlers?)` 展开到各个触发元素上。该工厂是普通函数,可以在循环中调用;它统一处理右键、Shift+F10、ContextMenu 键、视口坐标及无滚动聚焦。已有处理器先运行,`preventDefault()` 保留其处理权,并防止组合或嵌套绑定重复处理。重复、输入法组合及无关按键保持原状。传入 `undefined` 保留原生行为,也可传入目标解析函数,在手势开始时读取业务状态。解析后的对象会在焦点变化前捕获。普通子组件通过 props 或现有 delegate 接收事件 props 或工厂,无需额外 React Context。特殊编辑器和 Canvas 可直接调用 `ctx.contextMenus.open()`。 +省略号触发器使用 `renderFactorySlot('contextMenu.button', { target, label, className?, size? })`。`label` 由调用方本地化;`size: 'sm'` 选择带标签提示的紧凑工具栏按钮,省略时保留调用方设置的行按钮尺寸,不显示提示。工厂统一管理焦点、指针和点击隔离、下边缘锚点与展开状态。每个已渲染的按钮只切换自己打开的弹出菜单:按钮移动或目标展示字段变化后,再次激活仍会关闭该菜单;另一个目标相同的按钮则打开自己的菜单。重复的右键和键盘手势仍沿用 4 像素规则。目标类别无需在此声明身份规则。仅当目标类别没有注册贡献时隐藏按钮,不提前解析操作。稳定的 `ctx.contextMenus.registeredKinds` observable 即时发布注册和注销,包括按钮挂载后才激活的提供方。已注册提供方即使返回空操作,按钮仍可用。 + ### 注册操作 在贡献插件中调用 `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。 diff --git a/packages/client/ui-context-menu/package.json b/packages/client/ui-context-menu/package.json index ab4cc48135..6ddabec73f 100644 --- a/packages/client/ui-context-menu/package.json +++ b/packages/client/ui-context-menu/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-context-menu", "description": "Typed, plugin-extensible context menus with captured targets and shared Web presentation", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -65,6 +65,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-context-menu/src/client/Button.tsx b/packages/client/ui-context-menu/src/client/Button.tsx new file mode 100644 index 0000000000..6549d63029 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/Button.tsx @@ -0,0 +1,53 @@ +/** Shared ellipsis control for explicit context-menu targets. */ +import { Button, IconEllipsisOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import { useId } from 'react' +import type { MouseEvent, PointerEvent } from 'react' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ContextMenus, ContextMenuTarget, MenuPoint } from './types.ts' + +/** Menu facts and the per-button toggle, bound by the factory registration. */ +export interface ContextMenuButtonInjected { + hooks: { + /** Instance id of the shared button that opened the current popup; null for gestures and after dismissal. */ + opener: ObservableSnapshot + registeredKinds: ContextMenus['registeredKinds'] + } + /** + * Close the popup this button opened, or open a new one for its target. + * @param button - instance id of the activated button. + * @param target - captured object. + * @param point - viewport anchor below the button. + */ + toggle: (button: string, target: ContextMenuTarget, point: MenuPoint) => void +} + +/** + * Render the menu trigger while its target kind has a registered contribution. + * @param props - Localized owner data and framework-bound menu facts. + * @returns An available target's menu trigger. + */ +export function ContextMenuButton({ target, label, className, size, useOpener, useRegisteredKinds, toggle }: FactoryComponentPropsOf<'contextMenu.button'>) { + const id = useId() + const expanded = useOpener(opener => opener === id) + const available = useRegisteredKinds(kinds => kinds.includes(target.kind)) + if (!available) return null + const attributes = { + className, + 'data-context-menu-trigger': '', + 'aria-label': label, + 'aria-haspopup': 'menu' as const, + 'aria-expanded': expanded, + onPointerDown: (event: PointerEvent) => { event.stopPropagation() }, + onClick: (event: MouseEvent) => { + event.stopPropagation() + event.currentTarget.focus({ preventScroll: true }) + const rect = event.currentTarget.getBoundingClientRect() + toggle(id, target, { x: rect.left, y: rect.bottom }) + }, + } + if (size !== 'sm') return + return + - select([])} useStore={useStore} actions={store.actions} close={close} select={run} + key === 'back' ? 'Back' : 'Failed'} useCatalog={select => select([])} useStore={useStore} actions={store.actions} close={close} select={run} renderSlot={(_key, owner: object) => { const { target, dismiss } = owner as { target: ContextMenuTarget; dismiss: () => void } return { @@ -55,7 +56,7 @@ it('renders disabled, grouped and nested data actions and keeps feedback after m { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original' }, { id: 'other', label: 'Other', separatorBefore: true }] }, { id: 'footer', label: 'Folder', afterContent: true }, ]) - const view = render( select([])} useStore={bindSnapshotSelector(store)} actions={store.actions} + const view = render( key === 'back' ? 'Back' : 'Failed'} useCatalog={select => select([])} useStore={bindSnapshotSelector(store)} actions={store.actions} close={() => { store.actions.close() }} select={select} renderSlot={() => null} />) expect(view.getByRole('menuitem', { name: 'Unavailable' }).hasAttribute('disabled')).toBe(true) fireEvent.click(view.getByRole('menuitem', { name: 'Open' })) @@ -90,7 +91,9 @@ it('dispatches captured icon keys in rows, submenus and footers and retires rend const runtime = await SlotTestRuntime.create() onTestFinished(() => runtime.dispose()) runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) - runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderSlot }) => <>{renderSlot('shell.overlay', {})}) const menuPlugin = await runtime.mount({ inject, apply }) @@ -143,7 +146,7 @@ it('tracks configured shortcuts for nested and footer actions without invoking c { id: 'footer', label: 'Footer', commandId, afterContent: true }, ]) const select = vi.fn() - const view = render( key === 'back' ? 'Back' : 'Failed'} useStore={bindSnapshotSelector(store)} actions={store.actions} useCatalog={bindSnapshotSelector(shortcuts.catalog)} close={vi.fn()} select={select} renderSlot={() => null} />) const item = view.getByRole('menuitem', { name: 'Rename' }) expect(item.getAttribute('aria-keyshortcuts')).toBe('Alt+Meta+G') diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts index afd3d97350..4d2ab784ba 100644 --- a/packages/client/ui-context-menu/tests/plugin.client.spec.ts +++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, expect, it, vi } from 'vitest' import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { MenuOverlayInjected } from '../src/client/View.tsx' +import type { ContextMenuButtonInjected } from '../src/client/Button.tsx' import { apply, inject } from '../src/client/index.ts' import { apply as hostApply } from '../src/index.ts' import type { createMenuStore } from '../src/client/store.ts' @@ -20,7 +21,7 @@ it('lets an external contribution open another menu and withdraws its actions wh ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) let overlay!: MenuOverlayInjected let store!: ReturnType['create']> - ctx.provide('slots', { provideRoot: () => () => {}, + ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {}, inject: (_name: string, register: Parameters[1]) => ctx.effect(register), register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType }) => { overlay = entry.inject(); store = entry.store.create(); return () => {} @@ -95,7 +96,7 @@ it('waits for metadata, toggles nearby repeated gestures, replaces occurrences, const ctx = new Context() ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) let store!: ReturnType['create']> - ctx.provide('slots', { provideRoot: () => () => {}, + ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {}, inject: (_name: string, register: Parameters[1]) => ctx.effect(register), register: (entry: { store: ReturnType }) => { store = entry.store.create(); return () => {} }, } as never) @@ -142,7 +143,7 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and const ctx = new Context() ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) let overlay!: MenuOverlayInjected - ctx.provide('slots', { provideRoot: () => () => {}, + ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {}, inject: (_name: string, register: Parameters[1]) => ctx.effect(register), register: (entry: { inject: () => MenuOverlayInjected }) => { overlay = entry.inject(); return () => {} }, } as never) @@ -166,6 +167,8 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and expect(overlay.returnFocus?.()).toBe(trigger) menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 2 })) menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + trigger.setAttribute('data-context-menu-trigger', '') + trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) expect(ctx.contextMenus.active.getSnapshot()).not.toBeNull() document.dispatchEvent(new MouseEvent('contextmenu', { cancelable: true })) @@ -185,7 +188,11 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and it('leaves unbound keyboard gestures native', async () => { const ctx = new Context() ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) - ctx.provide('slots', { provideRoot: () => () => {}, inject: () => () => {} } as never) + let button!: ContextMenuButtonInjected + ctx.provide('slots', { provideRoot: () => () => {}, + registerFactory: (entry: { inject: () => ContextMenuButtonInjected }) => { button = entry.inject(); return () => {} }, + inject: () => () => {}, + } as never) ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) const fiber = ctx.plugin({ inject, apply }) await fiber.await() @@ -194,4 +201,6 @@ it('leaves unbound keyboard gestures native', async () => { document.dispatchEvent(event) expect(event.defaultPrevented).toBe(false) } finally { await fiber.dispose() } + button.toggle('retired-button', { kind: 'pluginDocument', value: { id: 'retired' } }, { x: 1, y: 2 }) + expect(button.hooks.opener.getSnapshot()).toBeNull() }) diff --git a/packages/client/ui-context-menu/tests/registry.client.spec.ts b/packages/client/ui-context-menu/tests/registry.client.spec.ts index c533f3b49d..20f77e808b 100644 --- a/packages/client/ui-context-menu/tests/registry.client.spec.ts +++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts @@ -4,7 +4,7 @@ import { MenuRegistry } from '../src/client/registry.ts' import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { - interface ContextMenuTargetMap { extension: { readonly id: string } } + interface ContextMenuTargetMap { extension: { readonly id: string }; registryHeader: { readonly id: string } } } const target = { kind: 'extension' as const, value: { id: 'clicked' } } @@ -16,6 +16,35 @@ function deferred() { } describe('menu contribution lifecycle', () => { + it('publishes registered kinds immediately through one stable source without resolving actions', () => { + const registry = new MenuRegistry(vi.fn()), resolve = vi.fn(() => []) + const source = registry.registeredKinds + const changed = vi.fn() + const detach = source.subscribe(changed) + expect(source.getSnapshot()).toEqual([]) + const first = registry.register({ id: 'first', target: 'registryHeader', order: 0, resolve }) + expect(source.getSnapshot()).toEqual(['registryHeader']) + const snapshot = source.getSnapshot() + const second = registry.register({ id: 'second', target: 'registryHeader', order: 0, resolve }) + expect(source.getSnapshot()).toBe(snapshot) + first(); first() + expect(source.getSnapshot()).toBe(snapshot) + const other = registry.register({ id: 'other', target: 'session', order: 0, resolve }) + expect(source.getSnapshot()).toEqual(['registryHeader', 'session']) + second() + expect(source.getSnapshot()).toEqual(['session']) + other() + expect(source.getSnapshot()).toEqual([]) + registry.register({ id: 'replacement', target: 'registryHeader', order: 0, resolve }) + expect(registry.registeredKinds).toBe(source) + expect(source.getSnapshot()).toEqual(['registryHeader']) + registry.dispose() + expect(source.getSnapshot()).toEqual([]) + expect(changed).toHaveBeenCalledTimes(6) + expect(resolve).not.toHaveBeenCalled() + detach() + }) + it('orders independently resolved contributions and executes the captured target once', async () => { const failure = vi.fn(), run = vi.fn() const registry = new MenuRegistry(failure) @@ -67,6 +96,21 @@ describe('menu contribution lifecycle', () => { expect(() => registry.resolve(target)).toThrow('disposed') }) + it('replaces synchronous observed actions and retires their previous callbacks', () => { + const source = createSnapshotStore('first'), run = vi.fn() + const registry = new MenuRegistry(vi.fn()) + registry.register({ id: 'changing', target: 'extension', order: 0, observe: () => [source], + resolve: () => [{ id: source.getSnapshot(), label: source.getSnapshot(), run }] }) + const menu = registry.resolve(target) + source.set('second') + expect(menu.source.getSnapshot().map(action => action.id)).toEqual(['changing/second']) + menu.select('changing/first') + expect(run).not.toHaveBeenCalled() + menu.select('changing/second') + expect(run).toHaveBeenCalledOnce() + registry.dispose() + }) + it('contains provider failure and rejects duplicate contributions without corrupting other actions', async () => { const failed = vi.fn(), run = vi.fn(), registry = new MenuRegistry(failed) const definition = { id: 'good', target: 'extension' as const, order: 0, resolve: () => [{ id: 'a', label: 'A', run }] } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 00011393b7..c88fda7554 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,20 +21,29 @@ en: cc4dafd1aae02468 zh: be6b67724b7ac4a3 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: c11cc27a337174f3 - zh: a971ef53fa6046a2 + en: bd2a85b9012fa3c3 + zh: e88d7e682e316a0e /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: 59cd63057dfb6eeb zh: b2e2c86e810b7bb8 /deepseek-ai-dsh-client-ui-conversation/model-experience: - en: e0a05b73ba3e4318 - zh: dc85ab8433bb4456 -/deepseek-ai-dsh-client-ui-conversation/model-experience/kv-cache-effect: - en: 9f4e0da7c8addf88 - zh: 1a33d9981af75643 + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request: + en: 6a7ae99570c9c007 + zh: 26e86063e97d82c7 +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/what-the-model-sees: + en: 017391d7ad0f9eb7 + zh: 2693ed29d44b1164 +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/token-effect: + en: 517c56dbd522c929 + zh: 55fa3a02d026e36a +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/kv-cache-effect: + en: f8621bbc481c26ae + zh: 5be844989dc80803 /deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work: - en: edd7248fc7e110fb - zh: 643e313c9585b73b + en: b3f74c35f88ab2fa + zh: 8492b314debd130c /deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 76ed8e06ca..041aa0d5d4 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -80,6 +80,12 @@ The composer measures its expanded control groups after size, content, visibilit The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale). +An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted ordinary user input and a settled failed model attempt. Clicking submits `content: []` through the existing Session prompt API; drafts and attachments keep the Send action. A new balanced turn uses committed history and records an empty `user/message` with `source.kind: 'retry'`. Refusal or transport failure leaves the draft empty and reports the error through the ordinary submission lifecycle. + +The Session API owns `conversationContinuation`, shared by Host admission and browser presentation. It restores eligibility from complete Session history and preserves it across repeated failed empty continuations, independently of loaded browser pages. + +The composer exposes `retry(failureSeq)` for failed-generation recovery. The input bar reads `conversationContinuation` and `inbox` through the standard `useProjection` prop. User-facing text calls the action Continue generating. + The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page. The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a CSS-anchored panel bounded by the layout viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. Browser zoom and panning move the panel with the page, so it may extend beyond the visual viewport. Outside the Hero, that row keeps one reading's height while it is empty, so statistics and context usage arriving later do not change the composer's height; the Hero shows no empty row. @@ -104,6 +110,8 @@ The package occupies the root-scoped `main` key `conversation`. Its `main.conver A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps the blank-Session 40px row on every platform, so the Hero input card sits at the same height before and after a Workspace is bound; on macOS desktop that row is also the window drag row. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. When the title and actions do not fit, the title area clips them and fades its trailing edge before the utilities, which stay fully visible and clickable; overlays in the title and actions slots portal to stay outside that clip. +The Session Header owns its trailing More button and renders the shared `contextMenu.button` factory after utility contributions. This package declares the `sessionHeader` context-menu target `{ sessionId }`, independent of Session-row context actions; Header-menu providers import its client types. The button remains available without the export plugin while another Header-menu provider is registered; it hides when none are registered. Providers determine their own items and availability when the menu opens. [Context menus](../ui-context-menu/README.md) own button toggling and accessibility; [Session log export](../../session-query/session-log-export/README.md) owns export actions and global download dialogs. + View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. @@ -191,11 +199,19 @@ Session title context gestures use the shared [context menu](../ui-context-menu/ ## Model Experience -None, as this package renders browser state and sends user-admitted inputs through Session Controller APIs without constructing model requests. +### Manual continuation request + +#### What the model sees + +The play action submits `content: []` through the existing Session prompt API. Ordinary Loop admission records an empty `user/message` with `source.kind: 'retry'`. Chat presents the empty retry as a compact continuation-request receipt. The adapter applies its ordinary message serialization; the action adds no localized or hidden continuation text. Pending injected context follows ordinary admission. + +#### Token effect + +The action adds no continuation text. Empty-message serialization and framing costs depend on the adapter. The request still incurs the ordinary cost of retained history, current prompt sections, tool declarations, and any newly admitted context. #### KV Cache effect -None; Conversation assembly and browser input state do not alter provider-side prompt caching. +Serialization of the empty retry message depends on the adapter. Prefix reuse depends on the current model, prompt, tools, injected context, and provider cache policy. ## Known Limitations and Deferred Work @@ -203,6 +219,7 @@ None; Conversation assembly and browser input state do not alter provider-side p - **Only registered targets can render** — the shell deliberately has no implicit fallback target beyond the registered `chat` preference. - **Factory occurrences inherit their render-position Session** — `conversation.content` does not accept an independently addressed Session; that requires a separate Session-provider capability. +- **Manual continuation uses a fixed product allowlist** — the supported failure codes are independent of the provider's configurable `retryableCodes`; changing automatic retry policy does not expand or reduce this action. `STREAM_CLOSED` permits an explicit new user turn even when the default policy does not retry it. That code can also identify a protocol violation, so another request is not guaranteed to recover. - **Composer backdrop blur is verified only in Chromium** — with `will-change: transform` on the docked composer, descendant `backdrop-filter` still samples the transcript in Chromium; WebKit behavior has not been verified. - **A failing Definition costs a full replay per publication** — after an incremental flush fails, the assembler replays the complete loaded window to keep the views correct. A Definition that fails on every update therefore replays the whole window on each publication until its owner fixes it ([window update paths](../../../docs/subsystems/conversation.md#window-update-paths)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1101262976..0ac0d779d1 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -80,6 +80,12 @@ root 作用域的 hoc Slot `conversation.hero.frame` 在所有阶段都包裹滚 上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。 +空闲且草稿为空的普通 Session,在最新轮次以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束后显示播放按钮。失败工作必须有已准入的普通用户输入和失败请求尝试。点击通过现有 Session prompt API 提交 `content: []`;已有草稿或附件仍使用发送按钮。新轮次正常闭合,使用已提交历史,并记录 `source.kind: 'retry'` 的空 `user/message`。请求被拒绝或传输失败时,草稿保持为空,错误沿用普通提交流程展示。 + +Session API 拥有 `conversationContinuation`,Host 准入和浏览器展示共用该投影。它从完整 Session 历史恢复资格,并在空输入继续再次失败时保留资格,不依赖浏览器已加载的历史分页。 + +composer 提供 `retry(failureSeq)` 处理生成失败后的恢复。输入栏通过标准 `useProjection` prop 读取 `conversationContinuation` 和 `inbox`。面向用户的操作文案为“继续生成”。 + composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在受布局视口约束的 CSS 锚定面板中查看 token 构成,没有统计项时也可打开;上下文用量和容量尚不可用时,按钮保持隐藏。浏览器缩放和平移时面板随页面移动,因此可以超出视觉视口。Hero 之外,这一行为空时仍保留一项读数的高度,因此会话统计和上下文用量稍后到达不会改变 composer 的高度;Hero 不显示空行。 @@ -104,6 +110,8 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在所有平台都保留与 blank Session 相同的 40px 行,因此绑定 Workspace 前后 Hero 输入卡片的高度位置不变;在 macOS 桌面上,这一行同时是窗口拖拽行。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。标题与 actions 放不下时,标题区域会裁剪它们,并在 utilities 之前渐隐其末端;utilities 始终完整可见且可点击;标题与 actions slot 中的浮层通过 portal 渲染,不受该裁剪影响。 +Session Header 持有末尾的更多按钮,在 utility 贡献之后渲染共享 `contextMenu.button` factory。本包声明 `sessionHeader` 右键菜单目标 `{ sessionId }`,独立于会话行右键操作;Header 菜单提供者导入本包的 client 类型。只要仍注册有其他 Header 菜单提供者,未挂载导出插件时按钮也可用;没有任何提供者时隐藏。各提供者在菜单打开时决定自己的条目及可用性。[右键菜单](../ui-context-menu/README.zh.md)负责按钮开关与可访问性;[会话日志导出](../../session-query/session-log-export/README.zh.md)负责导出操作与全局下载弹窗。 + View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 @@ -191,11 +199,19 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match ## 模型体验 -无,因为本包渲染浏览器状态,并通过 Session Controller API 发送用户确认提交的输入,而不构造模型请求。 +### 手动继续请求 + +#### 模型看到什么 + +播放按钮通过现有 Session prompt API 提交 `content: []`。普通 Loop 准入记录带 `source.kind: 'retry'` 的空 `user/message`。Chat 将空重试消息显示为轻量的继续请求记录。适配器按普通消息序列化处理;该操作不添加本地化或隐藏的继续文本。待注入上下文遵循普通准入流程。 + +#### Token 影响 + +该操作不增加继续文本。空消息的序列化及封装开销取决于适配器。请求仍承担保留历史、当前提示词、工具声明以及新准入上下文的正常输入成本。 #### KV Cache 影响 -无;Conversation 组装和浏览器输入状态不会改变提供方侧的 prompt cache。 +空重试消息的序列化取决于适配器。前缀复用取决于当前模型、提示词、工具、注入上下文及提供方缓存策略。 ## 已知限制与暂缓事项 @@ -203,6 +219,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match - **只有已注册 target 可以渲染**——除已注册的 `chat` 偏好外,shell 刻意不提供隐式 fallback target。 - **Factory occurrence 继承渲染位置的 Session**——`conversation.content` 不接受独立寻址的 Session;该能力需要单独的 Session provider。 +- **手动继续使用固定产品白名单**——支持的失败码独立于提供方可配置的 `retryableCodes`;修改自动重试策略不会扩展或缩小此操作的范围。即使默认策略不重试 `STREAM_CLOSED`,用户仍可明确发起新轮次。该错误码也可能表示协议违规,因此再次请求不保证能够恢复。 - **composer 内的毛玻璃只在 Chromium 中验证过**——docked composer 设置 `will-change: transform` 后,Chromium 中后代的 `backdrop-filter` 仍会采样背后的 transcript;WebKit 中的表现尚未验证。 - **出错的 Definition 让每次发布都完整回放**——增量 flush 失败后,assembler 回放已加载的完整窗口,以保持视图正确。若某个 Definition 在每次更新中都失败,在其 owner 修复前,每次发布都会回放整个窗口([Window 更新路径](../../../docs/subsystems/conversation.zh.md#window-update-paths))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 7cb2fb0bba..612f987620 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -59,7 +59,6 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", - "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-file-upload": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", @@ -87,7 +86,6 @@ "react": "^18.2.0", "@lexical/headless": "^0.49.0", "react-dom": "^18.2.0", - "zod": "4.4.3", "lexical": "^0.49.0", "clsx": "^2.0.0", "@lexical/utils": "^0.49.0", @@ -99,12 +97,18 @@ "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-message-quotes": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "zod": "4.6.6", "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*" }, "files": [ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index aee9351ce0..aae71928be 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -484,6 +484,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { retryFileUpload: undefined, toggleCommandMenu: undefined, stop: undefined, + retry: undefined, hooks: { stopShortcut, busyEnter: submissionPolicy.busyEnter, @@ -563,6 +564,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) }, stop: () => { stop(sessionId) }, + retry: (failureSeq) => { inputHub.retry(sessionId, failureSeq) }, hooks: { stopShortcut, busyEnter: submissionPolicy.busyEnter, diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 82f030c775..6f6b0f1436 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -352,6 +352,8 @@ export type InputEvent = /** Enter submission with the current clipboard projection, its reference chips, its quotes, and its attachments. */ | { readonly type: 'enter' + /** Explicit retry may submit an empty draft through the default sink. */ + readonly allowEmpty?: boolean readonly mode: InputSubmitMode readonly draft: string readonly occurrences: readonly Occurrence[] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5ce37d9ab9..e7ae7285ab 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -14,7 +14,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionPendingInteraction } from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ComposerBlock } from './composer-blocks.ts' import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts' @@ -25,6 +25,13 @@ import type { ConversationSnapshot } from './snapshot.ts' import type { ViewTab } from './views.ts' import type { UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode } from './user-text.ts' +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { + /** Session Header More menu, independent of Session-row and title context actions. */ + sessionHeader: { readonly sessionId: SessionId } + } +} + /** Browser-owned draft attachment that has not crossed the durable Host boundary. */ export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment @@ -568,6 +575,11 @@ export interface ComposerBarInjected { retryFileUpload: ((id: DraftAttachmentId) => void) | undefined toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined + /** + * Submit empty input to retry the displayed failed generation. + * @param failureSeq - sequence of the displayed failed turn/end. + */ + retry: ((failureSeq: SessionSeq) => void) | undefined hooks: { /** Readable sequence while the fixed Stop command is registered. */ stopShortcut: ObservableSnapshot @@ -682,6 +694,7 @@ export type ConversationHeaderProps = /** Full props of the strict Session header. */ export type ConversationSessionHeaderSlotProps = PropsRuntime<'conversation.session.header'> + & PropsRenderFactories & PropsRenderSlots< 'conversation.session.header.lineage' | 'conversation.session.header.actions' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 6306216b1c..cb8472f746 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -387,8 +387,9 @@ export class SessionInputShell implements SessionInput { * out from the machine; this method only feeds the event. Lock entry * (adjudicating/submitting/sending) force-closes the transient layers: the * popup dismisses and the menu tracks frozen. + * @param allowEmpty - explicitly admit empty continuation input through the default sink. */ - submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void { + submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter', allowEmpty = false): void { if (this.disposed) return const timestamp = Date.now() let state: MessageSubmissionState | undefined @@ -409,6 +410,7 @@ export class SessionInputShell implements SessionInput { } this.dispatchRun({ type: 'enter', + ...allowEmpty ? { allowEmpty: true } : {}, mode, draft: this.projection.clipboardText, occurrences: [...this.projection.occurrences], @@ -726,6 +728,10 @@ export class SessionInputShell implements SessionInput { }) const send = (text: string): void => { if (this.dead(attempt)) return + if (text === '' && draft !== '' && attachmentIds.length === 0) { + this.settleSink(attempt, Promise.resolve({ kind: 'success' })) + return + } this.settleSink(attempt, this.deps.defaultSink(text, attachmentIds, mode, attempt.signal, () => { this.acceptSink(attempt) })) } if (!(prompt instanceof Promise)) { send(prompt); return } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 9ed94a567b..f6e9ce4183 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ISessions, SessionBinding, SessionFace, } from '@deepseek-ai/dsh-api-session-controller/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types' @@ -207,6 +207,29 @@ export class InputHub implements SessionInputResolver { return this.shell(id) } + /** + * Submit empty input to retry a failed generation through the ordinary composer. Stale + * failures, active work, and drafts keep their current input unchanged. + * @param id - retained ordinary Session identity. + * @param failureSeq - failed turn/end sequence the displayed button addresses. + */ + retry(id: SessionId, failureSeq: SessionSeq): void { + const binding = this.sessions().binding(id) + if (binding === undefined + || binding.session.projections.faceOf('conversationContinuation').getSnapshot() !== failureSeq) return + const session = binding.session.getSnapshot() + const shell = this.shellFor(binding) + const input = shell.snapshot + const inbox = binding.session.projections.faceOf('inbox').getSnapshot() as InboxState | undefined + if (session.running || session.removed || session.openState !== 'open' || session.subagent !== null + || session.pendingSubmissions.length > 0 || input.phase !== 'plain' + || input.draft.trim() !== '' || input.attachmentIds.length > 0 + || (inbox?.['next-turn'].length ?? 0) > 0 + || inbox?.['next-step'].some(message => message.source.kind === 'user') + || this.conversation().blocks.storeFor(id).getSnapshot() !== undefined) return + shell.submit('queue', 'click', true) + } + /** * Query file intake without creating a Session input. * @param id - target Session. @@ -251,7 +274,9 @@ export class InputHub implements SessionInputResolver { signal: AbortSignal, accept: () => void, ): Promise { - if (text === '' && attachmentIds.length === 0) return Promise.resolve({ kind: 'success' }) + if (text === '' && attachmentIds.length === 0) { + return session.prompt([], mode, signal).then(result => result.ok ? { kind: 'success' } : { kind: 'error' }) + } return this.conversation().sendSession(session, text, attachmentIds, mode, signal, accept) } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 16fe7dd034..8545f964e5 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -144,7 +144,7 @@ export class SubmitMachine { return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }] } const trimmed = draft.trim() - if (trimmed === '' && attachmentIds.length === 0) return [] + if (trimmed === '' && attachmentIds.length === 0 && ev.allowEmpty !== true) return [] const attempt = this.beginAttempt(mode, draft, occurrences, quotes, attachmentIds, submission) if (trimmed.startsWith('/')) { this.phase = 'adjudicating' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9da780b793..eb3c04782a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -27,6 +27,7 @@ export const zh = { 'input.file': '文件', 'input.stop': '停止生成', 'input.send': '发送消息', + 'input.continue': '继续生成', 'input.sending': '正在发送消息', 'input.send.queue': '排队发送', 'input.send.steer': '插话发送', @@ -78,6 +79,7 @@ export const zh = { 'hero.headline': '探索未至之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', + 'header.more': '更多操作', 'session.hierarchy': '会话层级', 'session.moreViews': '更多会话视图', 'todo.title': '任务', @@ -198,6 +200,9 @@ export const zh = { 'detail.status.inactive': '未运行', 'detail.status.provisioning': '准备中', 'detail.status.failed': '失败', + 'detail.status.corrupt': '已损坏', + 'detail.status.unsupported': '不支持', + 'detail.status.unavailable': '不可用', 'detail.status.completed': '已完成', 'detail.status.deleted': '已删除', 'detail.status.killed': '已取消', @@ -338,6 +343,9 @@ export const zh = { 'tool.title.removeCordis': '移除 Cordis 插件', 'tool.title.pwsh': '运行命令', 'tool.title.readImage': '读取图片', + 'tool.title.officeRender': '渲染文档', + 'tool.title.officeConvert': '转换文档', + 'tool.title.officeRecalculate': '重算工作簿', 'tool.title.grep': '搜索文件内容', 'tool.title.glob': '查找文件', 'tool.title.webSearch': '网页搜索', @@ -418,6 +426,7 @@ export const en = { 'input.file': 'File', 'input.stop': 'Stop generating', 'input.send': 'Send message', + 'input.continue': 'Continue generating', 'input.sending': 'Sending message', 'input.send.queue': 'Queue message', 'input.send.steer': 'Steer message', @@ -469,6 +478,7 @@ export const en = { 'hero.headline': 'Into the Unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', + 'header.more': 'More actions', 'session.hierarchy': 'Session hierarchy', 'session.moreViews': 'More conversation views', 'todo.title': 'To-dos', @@ -589,6 +599,9 @@ export const en = { 'detail.status.inactive': 'Inactive', 'detail.status.provisioning': 'Provisioning', 'detail.status.failed': 'Failed', + 'detail.status.corrupt': 'Corrupt', + 'detail.status.unsupported': 'Unsupported', + 'detail.status.unavailable': 'Unavailable', 'detail.status.completed': 'Completed', 'detail.status.deleted': 'Deleted', 'detail.status.killed': 'Cancelled', @@ -729,6 +742,9 @@ export const en = { 'tool.title.removeCordis': 'Remove Cordis Plugin', 'tool.title.pwsh': 'Pwsh', 'tool.title.readImage': 'Read image', + 'tool.title.officeRender': 'Render document', + 'tool.title.officeConvert': 'Convert document', + 'tool.title.officeRecalculate': 'Recalculate workbook', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Glob', 'tool.title.webSearch': 'Search', @@ -808,6 +824,7 @@ export const ja = { 'input.file': 'ファイル', 'input.stop': '生成を停止', 'input.send': 'メッセージを送信', + 'input.continue': '生成を続ける', 'input.sending': 'メッセージを送信中', 'input.send.queue': 'メッセージをキューに追加', 'input.send.steer': '実行中の指示にメッセージを追加', @@ -859,6 +876,7 @@ export const ja = { 'hero.headline': '未知への挑戦', 'hero.preview': 'プレビュー', 'hero.chooseWorkspace': 'ワークスペースを選択', + 'header.more': 'その他の操作', 'session.hierarchy': 'セッション階層', 'session.moreViews': 'その他の会話ビュー', 'todo.title': 'ToDo', @@ -979,6 +997,9 @@ export const ja = { 'detail.status.inactive': '非アクティブ', 'detail.status.provisioning': 'プロビジョニング中', 'detail.status.failed': '失敗', + 'detail.status.corrupt': '破損', + 'detail.status.unsupported': '未対応', + 'detail.status.unavailable': '利用不可', 'detail.status.completed': '完了', 'detail.status.deleted': '削除済み', 'detail.status.killed': 'キャンセル済み', @@ -1119,6 +1140,9 @@ export const ja = { 'tool.title.removeCordis': 'Cordis プラグインを削除', 'tool.title.pwsh': 'Pwsh', 'tool.title.readImage': '画像を読み取り', + 'tool.title.officeRender': '文書を描画', + 'tool.title.officeConvert': '文書を変換', + 'tool.title.officeRecalculate': 'ブックを再計算', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Glob', 'tool.title.webSearch': '検索', @@ -1196,6 +1220,7 @@ export const es = { 'input.file': 'Archivo', 'input.stop': 'Detener generación', 'input.send': 'Enviar mensaje', + 'input.continue': 'Continuar generando', 'input.sending': 'Enviando mensaje', 'input.send.queue': 'mensaje de cola', 'input.send.steer': 'Redirigir mensaje', @@ -1247,6 +1272,7 @@ export const es = { 'hero.headline': 'Hacia lo desconocido', 'hero.preview': 'Vista previa', 'hero.chooseWorkspace': 'Elige espacio de trabajo', + 'header.more': 'Más acciones', 'session.hierarchy': 'Jerarquía de sesiones', 'session.moreViews': 'Más vistas de conversación', 'todo.title': 'Tareas pendientes', @@ -1362,6 +1388,9 @@ export const es = { 'detail.status.inactive': 'Inactivo', 'detail.status.provisioning': 'Aprovisionamiento', 'detail.status.failed': 'Fallido', + 'detail.status.corrupt': 'Dañado', + 'detail.status.unsupported': 'No compatible', + 'detail.status.unavailable': 'No disponible', 'detail.status.completed': 'Completado', 'detail.status.deleted': 'Eliminado', 'detail.status.killed': 'Cancelado', @@ -1502,6 +1531,9 @@ export const es = { 'tool.title.removeCordis': 'Eliminar el complemento Cordis', 'tool.title.pwsh': 'Pwsh', 'tool.title.readImage': 'Leer imagen', + 'tool.title.officeRender': 'Representar documento', + 'tool.title.officeConvert': 'Convertir documento', + 'tool.title.officeRecalculate': 'Recalcular libro', 'tool.title.grep': 'grep', 'tool.title.glob': 'globo', 'tool.title.webSearch': 'Buscar', @@ -1586,6 +1618,7 @@ export const fr = { 'input.file': 'Fichier', 'input.stop': 'Arrêter la génération', 'input.send': 'Envoyer le message', + 'input.continue': 'Continuer la génération', 'input.sending': 'Envoi du message', 'input.send.queue': "Message de file d'attente", 'input.send.steer': 'Guider avec ce message', @@ -1637,6 +1670,7 @@ export const fr = { 'hero.headline': "Dans l'inconnu", 'hero.preview': 'Aperçu', 'hero.chooseWorkspace': 'Choisir un espace de travail', + 'header.more': 'Autres actions', 'session.hierarchy': 'Hiérarchie des sessions', 'session.moreViews': 'Plus de vues de conversations', 'todo.title': 'À faire', @@ -1752,6 +1786,9 @@ export const fr = { 'detail.status.inactive': 'Inactif', 'detail.status.provisioning': 'Approvisionnement', 'detail.status.failed': 'Échec', + 'detail.status.corrupt': 'Corrompu', + 'detail.status.unsupported': 'Non pris en charge', + 'detail.status.unavailable': 'Indisponible', 'detail.status.completed': 'Terminé', 'detail.status.deleted': 'Supprimé', 'detail.status.killed': 'Annulé', @@ -1892,6 +1929,9 @@ export const fr = { 'tool.title.removeCordis': 'Supprimer le plug-in Cordis', 'tool.title.pwsh': 'Pwsh', 'tool.title.readImage': "Lire l'image", + 'tool.title.officeRender': 'Afficher le document', + 'tool.title.officeConvert': 'Convertir le document', + 'tool.title.officeRecalculate': 'Recalculer le classeur', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Glob', 'tool.title.webSearch': 'Rechercher', @@ -1976,6 +2016,7 @@ export const de = { 'input.file': 'Datei', 'input.stop': 'Generierung stoppen', 'input.send': 'Nachricht senden', + 'input.continue': 'Generierung fortsetzen', 'input.sending': 'Nachricht wird gesendet', 'input.send.queue': 'Warteschlangennachricht', 'input.send.steer': 'Mit Nachricht lenken', @@ -2027,6 +2068,7 @@ export const de = { 'hero.headline': 'Ins Unbekannte', 'hero.preview': 'Vorschau', 'hero.chooseWorkspace': 'Wählen Sie einen Arbeitsbereich', + 'header.more': 'Weitere Aktionen', 'session.hierarchy': 'Sitzungshierarchie', 'session.moreViews': 'Weitere Konversationsansichten', 'todo.title': 'Aufgaben', @@ -2142,6 +2184,9 @@ export const de = { 'detail.status.inactive': 'Inaktiv', 'detail.status.provisioning': 'Bereitstellung', 'detail.status.failed': 'Fehlgeschlagen', + 'detail.status.corrupt': 'Beschädigt', + 'detail.status.unsupported': 'Nicht unterstützt', + 'detail.status.unavailable': 'Nicht verfügbar', 'detail.status.completed': 'Abgeschlossen', 'detail.status.deleted': 'Gelöscht', 'detail.status.killed': 'Abgesagt', @@ -2282,6 +2327,9 @@ export const de = { 'tool.title.removeCordis': 'Entfernen Sie das Cordis-Plugin', 'tool.title.pwsh': 'Pwsh', 'tool.title.readImage': 'Bild lesen', + 'tool.title.officeRender': 'Dokument rendern', + 'tool.title.officeConvert': 'Dokument konvertieren', + 'tool.title.officeRecalculate': 'Arbeitsmappe neu berechnen', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Globus', 'tool.title.webSearch': 'Suchen', @@ -2366,6 +2414,7 @@ export const ru = { 'input.file': 'Файл', 'input.stop': 'Остановить генерацию', 'input.send': 'Отправить сообщение', + 'input.continue': 'Продолжить генерацию', 'input.sending': 'Отправка сообщения', 'input.send.queue': 'Сообщение в очереди', 'input.send.steer': 'Направить сообщение', @@ -2417,6 +2466,7 @@ export const ru = { 'hero.headline': 'В неизвестность', 'hero.preview': 'Предварительный просмотр', 'hero.chooseWorkspace': 'Выберите рабочее пространство', + 'header.more': 'Другие действия', 'session.hierarchy': 'Иерархия сеансов', 'session.moreViews': 'Больше просмотров беседы', 'todo.title': 'Дела', @@ -2532,6 +2582,9 @@ export const ru = { 'detail.status.inactive': 'Неактивный', 'detail.status.provisioning': 'обеспечение', 'detail.status.failed': 'Не удалось', + 'detail.status.corrupt': 'Повреждено', + 'detail.status.unsupported': 'Не поддерживается', + 'detail.status.unavailable': 'Недоступно', 'detail.status.completed': 'Завершено', 'detail.status.deleted': 'Удалено', 'detail.status.killed': 'Отменено', @@ -2672,6 +2725,9 @@ export const ru = { 'tool.title.removeCordis': 'Удалить плагин Cordis', 'tool.title.pwsh': 'Пшш', 'tool.title.readImage': 'Читать изображение', + 'tool.title.officeRender': 'Отрисовать документ', + 'tool.title.officeConvert': 'Преобразовать документ', + 'tool.title.officeRecalculate': 'Пересчитать книгу', 'tool.title.grep': 'Греп', 'tool.title.glob': 'Глоб', 'tool.title.webSearch': 'Поиск', @@ -2756,6 +2812,7 @@ export const pt = { 'input.file': 'Arquivo', 'input.stop': 'Parar geração', 'input.send': 'Enviar mensagem', + 'input.continue': 'Continuar gerando', 'input.sending': 'Enviando mensagem', 'input.send.queue': 'Mensagem de fila', 'input.send.steer': 'Orientar com mensagem', @@ -2807,6 +2864,7 @@ export const pt = { 'hero.headline': 'Para o desconhecido', 'hero.preview': 'Visualização', 'hero.chooseWorkspace': 'Escolha o espaço de trabalho', + 'header.more': 'Mais ações', 'session.hierarchy': 'Hierarquia de sessão', 'session.moreViews': 'Mais visualizações de conversas', 'todo.title': 'Tarefas', @@ -2922,6 +2980,9 @@ export const pt = { 'detail.status.inactive': 'Inativo', 'detail.status.provisioning': 'Provisionamento', 'detail.status.failed': 'Falha', + 'detail.status.corrupt': 'Corrompido', + 'detail.status.unsupported': 'Não suportado', + 'detail.status.unavailable': 'Indisponível', 'detail.status.completed': 'Concluído', 'detail.status.deleted': 'Excluído', 'detail.status.killed': 'Cancelado', @@ -3062,6 +3123,9 @@ export const pt = { 'tool.title.removeCordis': 'Remover plug-in Cordis', 'tool.title.pwsh': 'Pwsh', 'tool.title.readImage': 'Ler imagem', + 'tool.title.officeRender': 'Renderizar documento', + 'tool.title.officeConvert': 'Converter documento', + 'tool.title.officeRecalculate': 'Recalcular pasta de trabalho', 'tool.title.grep': 'Grep', 'tool.title.glob': 'globo', 'tool.title.webSearch': 'Pesquisar', diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css index 494b1997de..6d50c03311 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css @@ -142,16 +142,15 @@ } .colorSystem { - --meter-tint: var(--dsw-static-neutral-bluish-400); + --meter-tint: var(--dsw-alias-chart-system); } .colorTools { - /* The design platform ships no purple static token; violet-400 literal. */ - --meter-tint: rgb(167, 139, 250); + --meter-tint: var(--dsw-alias-chart-tools); } .colorMessages { - --meter-tint: var(--dsw-static-blue-450); + --meter-tint: var(--dsw-alias-chart-messages); } .rows { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index fa1c3a3722..39d6f62fef 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -193,6 +193,18 @@ display: none; } +.headerMenuButton { + flex: none; + width: 28px; + padding: 0; + color: var(--dsw-alias-label-secondary); +} + +.headerMenuButton svg { + width: 15px; + height: 15px; +} + /* The far-right corner seat reaches 16px into the header's 28px right padding, so its control sits past the utilities' edge; it is laid out only while its occupant renders something. Its left margin equals the utilities' 8px gap so diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index cc309bbfab..83b3af7029 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -107,7 +107,7 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum */ export function ConversationSessionHeader({ sessionId, hideChrome, useSessions, useConversationViews, useStore, - renderSlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions, + renderSlot, renderFactorySlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions, }: ConversationSessionHeaderProps) { const { getTriggerProps } = useContextMenu() const tabs = useConversationViews(value => value) @@ -208,6 +208,10 @@ export function ConversationSessionHeader({
{renderSlot('conversation.session.header.utilities', {})} + {renderFactorySlot('contextMenu.button', { + target: { kind: 'sessionHeader', value: { sessionId } }, + label: t('header.more'), className: css.headerMenuButton, size: 'sm', + })}
)} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index b3cd1dd271..04c44e252f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -405,9 +405,9 @@ border-radius: 999px; corner-shape: round; background: var(--dsw-alias-button-info-fill); - /* Static white, not the foreground token: the arrow stays white on the blue + /* On-color ink, not the foreground token: the arrow stays white on the blue fill in both themes (design 34:10465). */ - color: #fff; + color: var(--dsw-alias-label-on-color); cursor: pointer; transition: background-color 100ms ease; /* Opts out of the row's 2px downward shift (.row top pad): the send circle @@ -416,6 +416,11 @@ transform: translateY(-2px); } +/* Solid artwork uses a smaller footprint than the thin send arrow. */ +.continueIcon { + transform: scale(1.5); +} + @media (hover: hover) { .primary:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover); diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 2e66d24364..5b6407242d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react' import clsx from 'clsx' import { - focusWithoutRing, IconPlusOutlineMedium, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip, + focusWithoutRing, IconPlusOutlineMedium, IconTriangleRightFillRegular, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -46,7 +46,7 @@ export type InputBarProps = ComposerBarProps export const InputBar = memo(function InputBar({ useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, - toggleCommandMenu, stop, t, + toggleCommandMenu, stop, retry, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut, useAccess, useProjection, sessionId, variant, disabled: inert = false, blocked, @@ -70,6 +70,14 @@ export const InputBar = memo(function InputBar({ useEffect(() => { setActivity(false) }, [sessionId]) const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false + const retryableFailure = useProjection('conversationContinuation') ?? undefined + const pendingHumanInput = useProjection('inbox', inbox => + inbox?.['next-step'].some(message => + typeof message === 'object' && message !== null && 'source' in message + && typeof message.source === 'object' && message.source !== null && 'kind' in message.source + && message.source.kind === 'user') ?? false) + const historyOpen = useSession(s => s.openState === 'open') ?? false + const submissionPending = useSession(s => s.pendingSubmissions.length > 0) ?? false const subagent = useSession(s => s.subagent) ?? null const removed = useSession(s => s.removed) ?? false // Plan mode swaps the composer placeholder (the projection is the folded @@ -355,8 +363,12 @@ export const InputBar = memo(function InputBar({ // exposes Stop independently, and so does a running session while a // message waits for the Host. const primaryStops = running && subagent === null && !sending && (empty || blocked !== undefined) + const primaryRetries = !running && subagent === null && empty && retryableFailure !== undefined + && !pendingHumanInput && historyOpen && !submissionPending && !disabled && input.queue.length === 0 + && input.phase === 'plain' && retry !== undefined // Disabled native buttons may omit mouseleave; their tooltip must close from state. - const primaryDisabled = primaryStops ? stopAction === undefined : empty || disabled || machineBusy || uploadsPending + const primaryDisabled = primaryStops ? stopAction === undefined + : (!primaryRetries && empty) || disabled || machineBusy || uploadsPending const interruptible = running && (continuable || (sending && subagent === null)) const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') @@ -364,14 +376,20 @@ export const InputBar = memo(function InputBar({ ? t('input.stop') : sending ? t('input.sending') - : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft - ? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue') - : t('input.send') + : primaryRetries + ? t('input.continue') + : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft + ? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue') + : t('input.send') const onPrimary = (): void => { if (primaryStops) { stopAction?.() return } + if (primaryRetries) { + retry(retryableFailure) + return + } if (keyboard === undefined) return // absent machine: the button is disabled /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */ if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode, 'click') @@ -551,6 +569,8 @@ export const InputBar = memo(function InputBar({ + ) : primaryRetries ? ( + ) : ( diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index afb38b5fe0..90c4366a1a 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,4 +1,4 @@ -/** Host registration for browser conversation preferences. */ +/** Browser conversation preferences. */ import type {} from '@deepseek-ai/dsh-settings' import type { Volatile, Context } from '@deepseek-ai/cordis' @@ -24,7 +24,7 @@ export const Config = z.object({ [BUSY_ENTER_FIELD]: ConversationSettingsFields[BUSY_ENTER_FIELD].volatile(), }) -/** Host preferences are consumed through the configuration form projection. +/** Register browser conversation preferences. * @param ctx Plugin context used for optional settings presentation. */ export function apply(ctx: Context): void { diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index d968bdc4d8..e6384343ef 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -7,6 +7,7 @@ import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' +import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import * as messageQuotes from '@deepseek-ai/dsh-message-quotes/client' @@ -24,6 +25,10 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createConversationStore } from '../src/client/stores.ts' import type { SessionInputShell } from '../src/client/input/facade.ts' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { InboxWireState } from '@deepseek-ai/dsh-agent/types' +import { continuationTurn } from './continuation-fixture.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { @@ -161,6 +166,125 @@ async function bench(renderQuote = false) { } describe('Conversation inject API', () => { + it.each(['zh', 'en'])('submits empty continuation input in the %s locale', async (locale) => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'SERVER', message: 'busy' } })) + await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5)) + const composer = b.composerApi(ROOT) + b.runtime.ctx.locale.setLocale(locale) + const pending = Promise.withResolvers>>() + onTestFinished(() => { pending.resolve({ ok: true, value: { accepted: true } }) }) + b.sessionFake.prompt.mockImplementationOnce(() => pending.promise) + composer.retry!(SessionSeq(5)) + composer.retry!(SessionSeq(5)) + expect(composer.keyboard!.snapshot.phase).toBe('sending') + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() }) + expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([]) + expect(b.sessionFake.prompt.mock.calls[0]?.[1]).toBe('queue') + pending.resolve({ ok: true, value: { accepted: true } }) + await vi.waitFor(() => { expect(composer.keyboard!.snapshot.phase).toBe('plain') }) + expect(composer.keyboard!.snapshot.draft).toBe('') + }) + + it('rechecks the addressed failure, draft, and live activity before continuing', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'TIMEOUT', message: 'timeout' } })) + await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5)) + const composer = b.composerApi(ROOT) + const input = b.inputApi(ROOT) + composer.retry!(SessionSeq(1)) + input.actions.setDraft('Keep my new instruction') + composer.retry!(SessionSeq(5)) + expect(input.state.getSnapshot().draft).toBe('Keep my new instruction') + input.actions.setDraft('') + await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.running = true }) + composer.retry!(SessionSeq(5)) + await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.running = false }) + await b.runtime.sessions.appendEvent(ROOT, { + type: 'event', event: { type: 'turn/start', seq: SessionSeq(6), time: 1, data: { turn: 2 } }, + }) + await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', null) + composer.retry!(SessionSeq(5)) + expect(input.state.getSnapshot().draft).toBe('') + expect(b.sessionFake.prompt).not.toHaveBeenCalled() + }) + + it('keeps continuation available with injected mode context but excludes pending human steering', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5)) + const composer = b.composerApi(ROOT) + const context = { id: 'context', role: 'user', content: [{ type: 'text', text: 'approval policy context' }], source: { kind: 'user-approval' } } + const inbox: InboxWireState = { 'next-turn': [], 'next-step': [context] } + await b.runtime.sessions.setProjection(ROOT, 'inbox', inbox) + const user = createUserMessage({ content: [{ type: 'text', text: 'new instruction' }], source: { kind: 'user' } }) + await b.runtime.sessions.setProjection(ROOT, 'inbox', { ...inbox, 'next-step': [context, user] }) + composer.retry!(SessionSeq(5)) + expect(b.sessionFake.prompt).not.toHaveBeenCalled() + await b.runtime.sessions.setProjection(ROOT, 'inbox', inbox) + composer.retry!(SessionSeq(5)) + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() }) + expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([]) + }) + + it.each(['next-turn', 'pending', 'block', 'attachment', 'subagent', 'removed', 'closed'] as const)( + 'rejects a stale continuation click after %s changes', async (condition) => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5)) + const composer = b.composerApi(ROOT) + switch (condition) { + case 'pending': + await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.pendingSubmissions = [{ requestId: 'pending' as SessionRequestId, placement: 'transcript', time: 0, text: 'newer message', attachments: [] }] }) + break + case 'next-turn': + await b.runtime.sessions.setProjection(ROOT, 'inbox', { 'next-turn': [createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })], 'next-step': [] }) + break + case 'block': + b.runtime.ctx.conversation.blocks.set(ROOT, { reason: 'Choose a model' }) + break + case 'attachment': + expect(composer.addFiles?.([new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' })])).toBeNull() + break + case 'subagent': + await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.subagent = { address: { parentSessionId: ROOT, childSessionId: ROOT, mode: 'continuable' } } }) + break + case 'removed': + await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.removed = true }) + break + case 'closed': + await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.openState = 'error' }) + break + } + const before = b.inputApi(ROOT).state.getSnapshot().draft + composer.retry!(SessionSeq(5)) + expect(b.inputApi(ROOT).state.getSnapshot().draft).toBe(before) + expect(b.sessionFake.prompt).not.toHaveBeenCalled() + }, + ) + + it.each(['refused', 'failed'] as const)('keeps continuation input empty and exposes submission failure when %s', async (outcome) => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5)) + const composer = b.composerApi(ROOT) + if (outcome === 'refused') b.sessionFake.prompt.mockImplementationOnce(async () => { + const error = new RemoteError('gateway/internal', 'request refused', {}) + await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.promptError = { op: 'send', error } }) + return { ok: false, error } + }) + else b.sessionFake.prompt.mockRejectedValueOnce(new Error('connection lost')) + composer.retry!(SessionSeq(5)) + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce(); expect(composer.keyboard!.snapshot.phase).toBe('plain') }) + expect(composer.keyboard!.snapshot.draft).toBe('') + if (outcome === 'refused') expect(b.runtime.sessions.binding(ROOT)!.session.getSnapshot().promptError?.error.message).toBe('request refused') + else expect(composer.hooks.notices.getSnapshot()).toMatchObject({ level: 'error', text: 'connection lost' }) + composer.retry!(SessionSeq(5)) + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) }) + }) + it('renders literal quote text through the actual Factory and keyed node registration', async () => { const b = await bench(true) onTestFinished(() => b.runtime.dispose()) diff --git a/packages/client/ui-conversation/tests/config.host.spec.ts b/packages/client/ui-conversation/tests/config.host.spec.ts index 7b87d23d59..25420eff00 100644 --- a/packages/client/ui-conversation/tests/config.host.spec.ts +++ b/packages/client/ui-conversation/tests/config.host.spec.ts @@ -1,17 +1,22 @@ +import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import * as HostPlugin from '../src/index.ts' import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' import { plainConfig } from '../../../settings/settings/src/schema.ts' import { - DEFAULT_BUSY_ENTER_BEHAVIOR, Config, apply, + DEFAULT_BUSY_ENTER_BEHAVIOR, } from '@deepseek-ai/dsh-client-ui-conversation' describe('ui-conversation host', () => { it('registers, validates, and disposes the durable busy-Enter preference', async () => { const ctx = new Context() - const configuration = await liveConfig(ctx, { Config, apply }) + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const configuration = await liveConfig(ctx, HostPlugin) const { fiber } = configuration expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) await configuration.update({ busyEnter: 'steer' }) @@ -21,4 +26,9 @@ describe('ui-conversation host', () => { }) }) -it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(ctx => ctx.plugin(HostPlugin))) +it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(async (ctx) => { + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + return ctx.plugin(HostPlugin) +})) diff --git a/packages/client/ui-conversation/tests/continuation-fixture.ts b/packages/client/ui-conversation/tests/continuation-fixture.ts new file mode 100644 index 0000000000..392f01d39a --- /dev/null +++ b/packages/client/ui-conversation/tests/continuation-fixture.ts @@ -0,0 +1,28 @@ +/** Admitted input and a settled turn for manual-continuation tests. */ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { TurnEndReason } from '@deepseek-ai/dsh-session/types' + +/** + * Build one settled turn with ordinary user input. + * @param reason - recorded turn ending. + * @param number - turn number and corresponding event offset. + * @returns ordered durable entries. + */ +export function continuationTurn(reason: TurnEndReason, number = 1): { type: 'event'; event: SessionEvent }[] { + const offset = (number - 1) * 6 + return [ + { type: 'event', event: { type: 'turn/start', seq: SessionSeq(offset), time: 0, data: { turn: number } } }, + { type: 'event', event: { type: 'step/start', seq: SessionSeq(offset + 1), time: 0, data: { turn: number, step: 1 } } }, + { type: 'event', event: { + type: 'user/message', seq: SessionSeq(offset + 2), time: 0, surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text: 'task' }], source: { kind: 'user' } }), + } }, + { type: 'event', event: { + type: 'assistant/attempt', seq: SessionSeq(offset + 3), time: 0, data: { turn: number, step: 1, stream: [] }, + } }, + { type: 'event', event: { type: 'step/end', seq: SessionSeq(offset + 4), time: 0, data: { turn: number, step: 1 } } }, + { type: 'event', event: { type: 'turn/end', seq: SessionSeq(offset + 5), time: 0, data: { turn: number, reason } } }, + ] +} diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index a5aceea1b1..69499cdff4 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -26,7 +26,7 @@ import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-ses import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { SubmitOutcome } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts' @@ -81,6 +81,7 @@ interface BenchOptions { } draft?: string running?: boolean + retryableFailure?: SessionSeq subagent?: Exclude disabled?: boolean inert?: boolean @@ -161,6 +162,7 @@ function bench(over?: BenchOptions) { if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id)) const stop = vi.fn() + const retry = vi.fn() const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) }) const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const busyEnter = createSnapshotStore<'queue' | 'steer'>(over?.busyEnter ?? 'queue') @@ -197,12 +199,14 @@ function bench(over?: BenchOptions) { ...allowAllAccess, useContextMenu: contextMenuHook(), useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint), - useProjection: ((key: string, selector?: (v: unknown) => unknown) => - (selector ?? (v => v))(key === 'plan' + useProjection: ((key: string, selector: (v: unknown) => unknown = v => v) => + selector(key === 'plan' ? over?.plan : key === 'goal' ? over?.goal - : key === 'imageLimits' ? over?.imageLimits - : key === 'contextPressure' ? over?.contextPressure : undefined)), + : key === 'conversationContinuation' ? over?.retryableFailure + : key === 'inbox' ? { 'next-turn': over?.queue ?? [], 'next-step': over?.nextStep ?? [] } + : key === 'imageLimits' ? over?.imageLimits + : key === 'contextPressure' ? over?.contextPressure : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -216,6 +220,7 @@ function bench(over?: BenchOptions) { }), toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(), useBusyEnter: bindSnapshotSelector(busyEnter), + retry, useStopShortcut: bindSnapshotSelector(stopShortcut), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), @@ -252,20 +257,47 @@ function bench(over?: BenchOptions) { const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false) const primaryLabel = primaryStops ? '停止生成' - : over?.running === true && steeringAvailable && !composerLocked && plainMessageDraft - ? (over.busyEnter === 'steer' ? '插话发送' : '排队发送') - : '发送消息' + : over?.retryableFailure !== undefined && !sendableDraft && over.running !== true && over.subagent === undefined && !composerLocked + && !(over.nextStep ?? []).some(message => message.source.kind === 'user') + ? '继续生成' + : over?.running === true && steeringAvailable && !composerLocked && plainMessageDraft + ? (over.busyEnter === 'steer' ? '插话发送' : '排队发送') + : '发送消息' const button = view.container.querySelector(`button[aria-label="${primaryLabel}"]`)! const interruptButton = view.container.querySelector('button[aria-label="停止生成"]') return { view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls, - menuLauncher, busyEnter, stopShortcut, + menuLauncher, busyEnter, stopShortcut, retry, steerQueue: over?.steerQueue, get placeholder() { return placeholderOf(view.container) }, get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' }, } } +it('offers the play action only for an empty, idle composer with an eligible failure', async () => { + const b = bench({ retryableFailure: 2 as SessionSeq }) + expect(b.button.disabled).toBe(false) + expect(b.button.querySelector('svg')).not.toBeNull() + fireEvent.click(b.button) + expect(b.retry).toHaveBeenCalledExactlyOnceWith(2) + expect(b.sink).not.toHaveBeenCalled() + await act(async () => { b.shell.setDraft('A new instruction') }) + const send = b.view.getByRole('button', { name: '发送消息' }) + fireEvent.click(send) + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledOnce() }) + expect(b.sink.mock.calls[0]?.[0]).toBe('A new instruction') + expect(b.retry).toHaveBeenCalledOnce() +}) + +it.each([ + { running: true }, { attachments: [{ kind: 'image' as const, id: 'image' as DraftAttachmentId, file: new File([], 'x.png'), previewUrl: 'blob:test' }] }, + { blocked: { reason: 'Select a model' } }, { subagent: CONTINUABLE_CHILD }, { deniedEndpoints: ['session/prompt'] }, + { nextStep: [row('new instruction')] }, +])('keeps the continue action unavailable with %j', (options) => { + const b = bench({ ...options, retryableFailure: 2 as SessionSeq }) + expect(b.view.queryByRole('button', { name: '继续生成' })).toBeNull() +}) + function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps { for (let i = slotCalls.length - 1; i >= 0; i -= 1) { const call = slotCalls[i] diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 517848498a..a4963d15ac 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -86,6 +86,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled })), toggleCommandMenu: vi.fn(), useBusyEnter: bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue')), + retry: undefined, useStopShortcut: bindSnapshotSelector(createSnapshotStore([])), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 2129c605d2..82c9ba4d56 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -202,6 +202,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useMenuLauncher: bindSnapshotSelector(controller.launcher), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), + retry: undefined, useStopShortcut: bindSnapshotSelector(createSnapshotStore([])), t: makeTranslate(zh, commonZh), variant: 'composer', diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 28910ea625..2a099a7d7b 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -191,6 +191,7 @@ function mount( const stop = vi.fn() const open = vi.fn() const slotCalls: string[] = [] + const headerFactory = vi.fn(() => null) const lineageOwners: ConversationHeaderLineageOwnerProps[] = [] const viewTabs = options.viewTabs ?? [ { id: 'chat', label: 'Chat' }, @@ -252,6 +253,7 @@ function mount( useStore={bindSnapshotSelector(store)} actions={store.actions} renderSlot={renderSlot as never} + renderFactorySlot={headerFactory} open={open} selectView={(view) => { store.actions.setView(view) }} t={t} @@ -315,6 +317,7 @@ function mount( resolveDraftAttachments={() => []} toggleCommandMenu={vi.fn()} useBusyEnter={bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue'))} + retry={undefined} useStopShortcut={bindSnapshotSelector(createSnapshotStore([]))} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} @@ -404,7 +407,7 @@ function mount( const props: ConversationSlotProps = { ...runtimeProps, renderSlot, renderFactorySlot } const view = render() return { - view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open, + view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, headerFactory, lineageOwners, seatOwners, open, pickerOwner: () => pickerOwner, frameOwner: () => frameOwners.at(-1), setPinned, @@ -558,6 +561,9 @@ describe('ConversationRoot resident composer', () => { expect(b.slotCalls).toContain('conversation.session.header.actions') expect(b.slotCalls).toContain('conversation.session.header.utilities') expect(b.slotCalls).toContain('conversation.session.header.corner') + expect(b.headerFactory).toHaveBeenCalledWith('contextMenu.button', expect.objectContaining({ + target: { kind: 'sessionHeader', value: { sessionId: SID } }, label: zh['header.more'], size: 'sm', + })) }) it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => { @@ -585,6 +591,7 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByTestId('view-conversation.session.header.corner')).toBeTruthy() expect(b.view.queryByRole('tablist')).toBeNull() expect(b.slotCalls).not.toContain('conversation.session.header.utilities') + expect(b.headerFactory).not.toHaveBeenCalled() expect(b.slotCalls).not.toContain('conversation.session.header.actions') expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json index 0187cf5d72..c75fd6b50d 100644 --- a/packages/client/ui-conversation/tsconfig.client.json +++ b/packages/client/ui-conversation/tsconfig.client.json @@ -110,6 +110,7 @@ } ], "exclude": [ - "src/index.ts" + "src/index.ts", + "src/host" ] } diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index bd78bada2b..bd7b325586 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -88,7 +88,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index cd438b462c..7b24371c26 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -1,7 +1,6 @@ /** Changed-files card: filled header, file rows with line counts, and a bottom fold control. */ -.card { --changes-fill: var(--dsw-static-neutral-50); --changes-hover: var(--dsw-static-neutral-100); display: flex; flex-direction: column; min-width: 0; margin-top: 4px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); } +.card { --changes-fill: var(--dsw-alias-card-fill); --changes-hover: var(--dsw-alias-card-fill-hover); display: flex; flex-direction: column; min-width: 0; margin-top: 4px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); } .card[data-single='true'] { border-color: var(--dsw-alias-border-l1); } -:global(body[data-ds-dark-theme]) .card { --changes-fill: var(--dsw-static-neutral-850); --changes-hover: var(--dsw-static-neutral-800); } .header { display: flex; align-items: center; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; height: 60px; margin: 0; padding: 8px 10px; border: 0; background: var(--changes-fill); color: inherit; font: inherit; text-align: left; } button.header { cursor: pointer; transition: background-color 120ms ease; } button.header:focus-visible { background: var(--changes-hover); } @@ -11,8 +10,7 @@ button.header:focus-visible { background: var(--changes-hover); } } button.header:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); } button.header:disabled { cursor: progress; } -.tile { display: grid; flex: none; place-items: center; box-sizing: border-box; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); } -:global(body[data-ds-dark-theme]) .tile { background: color-mix(in srgb, var(--dsw-static-neutral-00) 5%, transparent); } +.tile { display: grid; flex: none; place-items: center; box-sizing: border-box; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-card-tile-fill); } .titles { display: flex; flex: 1; flex-direction: column; min-width: 0; } .title { overflow: hidden; font-size: 13px; font-weight: 500; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .stat { display: inline-flex; font-size: 10px; line-height: 16px; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index e5c439fe27..b89bfa2cbc 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,9 +1,8 @@ /** Delivery cards use one full-width row or a two-column, four-card summary. */ -.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; } +.root { --deliverable-fill: var(--dsw-alias-card-fill); --deliverable-hover: var(--dsw-alias-card-fill-hover); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; } /* The turn-tail gap alone separates adjacent file sections. The default top margin remains when deliveries are the first file section after prose. */ .root[data-after-changes='true'] { margin-top: 0; } -:global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); } .hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); } .presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; } .presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); } @@ -13,8 +12,7 @@ } .cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); } -.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); color: var(--dsw-alias-link); } -:global(body[data-ds-dark-theme]) .fileIcon { background: color-mix(in srgb, var(--dsw-static-neutral-00) 5%, transparent); } +.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-card-tile-fill); color: var(--dsw-alias-link); } .fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } .details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } .fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index fd2544f27d..034cd4647f 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -63,6 +63,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 400b76f094..daa2eb6b35 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -57,6 +57,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index f7f84767a5..87b424dfd9 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-dockkit", "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -34,7 +34,10 @@ "files": [ "lib/index.js", "lib/**/*.css", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/client/ui-entry-customization/package.json b/packages/client/ui-entry-customization/package.json index b017ae05dc..0d64c29522 100644 --- a/packages/client/ui-entry-customization/package.json +++ b/packages/client/ui-entry-customization/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-entry-customization", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "description": "Customize plugin entries in the left sidebar, conversation views, and right sidebar, and quickly create new plugins.", "publishConfig": { "access": "public" }, "repository": { @@ -18,7 +18,15 @@ "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, - "files": ["locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts"], + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" + ], "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { diff --git a/packages/client/ui-file-actions/package.json b/packages/client/ui-file-actions/package.json index 79cc8b9f86..07f9612828 100644 --- a/packages/client/ui-file-actions/package.json +++ b/packages/client/ui-file-actions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-file-actions", "description": "Original-file saving and image clipboard actions for Web and Desktop", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -68,6 +68,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-file-drag/package.json b/packages/client/ui-file-drag/package.json index 185cccb205..18225f7c99 100644 --- a/packages/client/ui-file-drag/package.json +++ b/packages/client/ui-file-drag/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-file-drag", "description": "Application-owned file drag transfers and localized failure feedback", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -59,6 +59,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index d6714efe7c..c46c077c89 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -72,6 +72,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 89f12badeb..469efd796f 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -66,6 +66,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 7f094bff7a..2c8943e4c7 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list with on-demand streaming record panels", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -53,7 +53,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "dsh": { "client": { diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 411c12966f..b418ce3e95 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -62,6 +62,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index fa1065dc56..dd4a89c814 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -122,42 +122,26 @@ :global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol { /* A blue wash over the top of the translucent tint, gone by a third of the - column; a faint blue-grey-violet rises through the bottom third; and a - faint blue cast is mixed into the tint itself. Every layer stays - transparent so vibrancy shows. */ + column, and a faint blue-grey-violet rising through the bottom third. + Every layer stays transparent so vibrancy shows; ui-theme sets each + palette's wash strength and tint. */ background: linear-gradient( to bottom, - rgb(122 155 240 / 0.1), - rgb(122 155 240 / 0) 35%, - rgb(143 137 184 / 0) 68%, - rgb(143 137 184 / 0.09) + var(--dsw-specific-sidebar-wash-top), + var(--dsw-specific-sidebar-wash-top-clear) 35%, + var(--dsw-specific-sidebar-wash-bottom-clear) 68%, + var(--dsw-specific-sidebar-wash-bottom) ), - color-mix(in srgb, color-mix(in srgb, var(--dsw-specific-sidebar-fill) 97%, rgb(122 155 240)) 40%, transparent); + var(--dsw-specific-sidebar-tint); border-right: none; } -/* Dark drops the blue cast (the light-mode wash reads purple over dark fills) - and thins the fill so more wallpaper shows; the washes stay. */ -:global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol { - background: - linear-gradient( - to bottom, - rgb(122 155 240 / 0.08), - rgb(122 155 240 / 0) 35%, - rgb(143 137 184 / 0) 68%, - rgb(143 137 184 / 0.07) - ), - color-mix(in srgb, var(--dsw-specific-sidebar-fill) 50%, transparent); -} - /* macOS Reduce Transparency: the OS flattens the window's vibrancy layer to a solid grey, so the thin tint and washes read washed-out over it. Replace - them with a 90%-opaque fill of the same token in both themes (the dark - selector repeats so this outranks its thinner fill). */ + them with a 90%-opaque fill of the same token in both themes. */ @media (prefers-reduced-transparency: reduce) { - :global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol, - :global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol { + :global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol { background: color-mix(in srgb, var(--dsw-specific-sidebar-fill) 90%, transparent); } } diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 3452a451f9..5c67e2ce03 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -73,6 +73,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 6af008e327..549bcfab73 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -72,6 +72,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 16367c590b..cfc7291505 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -3,32 +3,32 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md /: - en: be11314755d8f4f9 - zh: dd75b03074eea543 + en: 8caca7252f2cf5c7 + zh: 47a157352d33265e /deepseek-ai-dsh-client-ui-open-in-app: en: 004b805ac638e884 zh: 6ac98df58affefcf /deepseek-ai-dsh-client-ui-open-in-app/summary: - en: e69e4c6df4ddffa1 - zh: d1ef0a722f48e9bd + en: 1e68d4eb8f246ed2 + zh: d8bf9abe0c1cccee /deepseek-ai-dsh-client-ui-open-in-app/table-of-contents: en: d152484eb41ac6b4 zh: 725b47765ee8f30b /deepseek-ai-dsh-client-ui-open-in-app/use-this-package: - en: 056e2bd9e3c78dd8 - zh: be35d2ba53be0442 + en: b149da9963eb9837 + zh: 2426ea37fbd0f8b7 /deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect: - en: 51e6019f011f394e - zh: 818391262086ae10 + en: e07d3a607d54cb47 + zh: 9a7342f05b64db38 /deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation: - en: ec7a7c56aaeafce6 - zh: 4cee0cd2cb7531df + en: 22da59203ca974e6 + zh: f288bd6a193bccef /deepseek-ai-dsh-client-ui-open-in-app/further-exploration: - en: 562c58d589fb7afa - zh: 9ba88b381160cc96 + en: 720527877e452a97 + zh: b2241c7a336a3eeb /deepseek-ai-dsh-client-ui-open-in-app/model-experience: - en: 5f9e6e0fb0438628 - zh: fc9a81185ea6cb14 + en: aad141aa922e23f7 + zh: 18432b4f70971fa7 /deepseek-ai-dsh-client-ui-open-in-app/model-experience/kv-cache-effect: en: ca75c51c89c2c9b0 zh: c2241315dc0c89c9 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index c2fb730478..9b0f75963b 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -1,5 +1,5 @@ --- -description: "Web \"Open In...\" controls: the Session-header split button launching the remembered application on the workspace directory, and the document preview's default-application open and reveal controls for one file." +description: "Open Session directories from application submenus and open previewed files through their native applications." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the browser surface of the open-in-app feature. A Session-header split button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and its chevron lists every catalog application the host probed as installed; availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md), so mount the two packages together. In the right Sidebar's document preview, an "Open" split button and an empty-state button open the previewed file in its default application or show its location, through the Session Remote. A host without the capability renders none of these controls. +Open a Session’s directory through **Open in** in its Header More menu or its row/title context menu. The submenu lists installed, localized applications in Host order with icons and names, without a default marker. File-tree, document-preview, and delivery controls also open files or directories through native applications. Availability, icons, and launches come from [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount both packages together. A Session without a known directory or eligible application has no Open in item. ## Table of Contents @@ -25,23 +25,25 @@ This package provides the browser surface of the open-in-app feature. A Session- ## Use this package -Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) grows its file controls whenever the Host reports a desktop through the Session Remote's `session.canOpenWorkspacePath`; removing this row removes every control at once. +Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); this row takes no config. A known Session directory and at least one installed, localized catalog application enable its Open in submenu. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) shows file controls when the Host reports a desktop through `session.canOpenWorkspacePath`; removing this row removes all its controls and menu contributions. + +The Header entry uses the More button owned by [`ui-conversation`](../ui-conversation/README.md), with shared button behavior from [`ui-context-menu`](../ui-context-menu/README.md). Open in remains available there without the export plugin; this package supplies menu items rather than its own Header button. ### What to expect -The Session header, file-tree header, and document header use the same 24px-high split button with 9px corners. These headers show only the icon; their tooltip names the default application or the reveal action. They show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The file-tree opener targets the displayed directory supplied as `absolutePath` and shares the application choice and launch state with the Session header. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. +The Session Header places **Open in** in a separated group after download and before feedback. Session-row More, row right-click, and title right-click place it after Copy session ID and before Archive. Even one application stays in a submenu. The file-tree and document headers retain their 24px-high, 9px-radius icon-only split buttons. Their tooltips name the default application or reveal action, and their menus retain the default marker. All application icons fall back to a native-open glyph when loading fails. The file-tree opener targets its owner-supplied `absolutePath` and shares application choice and launch occupancy with Session menus. Directory choices persist in `dsh.open-in-app.choice` only after successful launches; unavailable choices fall back to the first eligible application. A busy directory launch disables both the submenu parent and all application rows. On iOS and Android phones and tablets, including installed PWAs, an acknowledged native open shows “Opened on another device”; reveal shows “File location shown on another device”. Desktop clients stay silent on success, including narrow browser windows and installed desktop PWAs. Every client receives a failure toast when the Host refuses or cannot be reached. Success means the Host accepted the native operation, not that the destination application finished loading the file. The feedback uses the existing mobile-device detector rather than viewport width or installation mode. -The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. +The **Open locally** shortcut captures the main Session’s directory and remembered application. The effective binding remains in the file-tree opener tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and that Session has a directory and installed application to open. An in-flight launch blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller’s launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. -Workspace context menus open the captured folder through the same directory application catalog as the Session header. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save. +Workspace context menus reveal the captured folder through the shared directory application catalog. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save. -Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Native context-menu operations share the feature's overlay toast and mobile/desktop feedback policy after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast. +Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session submenus resolve the captured Session ID’s directory rather than the main view, and observe application, operation, Session-list, and locale changes. Native operations report through the feature’s global toast after menu dismissal or target changes, without a second registry toast. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast. File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent. @@ -55,7 +57,7 @@ A file menu offers “Open in sidebar” and “Open in floating panel”, which
Implementation internals — click to expand -Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes the captured directory and busy state. Adapters resolve native and transport failures to named outcomes; ignored or cancelled requests return a skipped outcome and announce nothing. +[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across the shared context-menu package’s `session` and `sessionHeader` targets; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing. The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. @@ -67,7 +69,7 @@ The directory and file adapters supply application metadata and operations to [` ## Further Exploration - [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them. -- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action. +- [ui-conversation](../ui-conversation/README.md) — the Header owning the shared More button. - [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.md) — the document preview declaring the header and empty-state child slots the file controls occupy. - [ui-deliverables](../ui-deliverables/README.md) — the delivery cards, which still open declared files through their own routes. - [Web client architecture](../../../docs/subsystems/web-client.md) — how browser plugin rows load and register slots. @@ -77,7 +79,7 @@ The directory and file adapters supply application metadata and operations to [` ## Model Experience -None, as the split button is browser chrome; nothing here reaches a model request. +None, as these opening controls and menus are browser chrome; nothing here reaches a model request. #### KV Cache effect diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 04aa5edc1e..7bac1ae79f 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web \"Open In...\" 控件:会话头部在记住的应用中打开 workspace 目录的分体按钮,以及文档预览里用默认应用打开、显示单个文件位置的控件。" +description: "通过应用子菜单打开会话目录,并通过原生应用打开预览文件。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 open-in-app 功能的浏览器表面。会话头部的分体按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用;可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由,所以两个包要一起挂载。在右侧 Sidebar 的文档预览里,一个「打开」分体按钮和一个空态按钮经由 Session Remote 用默认应用打开当前文件或显示其位置。主机没有这项能力时,这些控件一个都不渲染。 +通过会话头部更多菜单或会话行、标题右键菜单中的**在应用中打开**打开会话目录。子菜单按 Host 顺序列出已安装且已本地化的应用,仅显示图标和名称,不标注默认应用。文件树、文档预览和交付控件也可通过原生应用打开文件或目录。可用性、图标和启动由 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 提供;两个包应一起挂载。会话目录未知或没有符合条件的应用时,不显示在应用中打开。 ## 目录 @@ -25,23 +25,25 @@ kind: "package-reference" ## 使用本包 -把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。只要 Host 通过 Session Remote 的 `session.canOpenWorkspacePath` 报告有桌面,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览就会长出文件控件;删掉这一行会一次去掉所有控件。 +把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂载到 Web 组合;本行不接受配置。会话目录已知且至少有一个已安装、已本地化的目录应用时,显示在应用中打开子菜单。Host 通过 `session.canOpenWorkspacePath` 报告桌面可用时,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览显示文件控件;删除本行会移除其全部控件与菜单贡献。 + +头部入口使用 [`ui-conversation`](../ui-conversation/README.zh.md) 持有的更多按钮,由 [`ui-context-menu`](../ui-context-menu/README.zh.md) 提供共享按钮行为。未挂载导出插件时,在应用中打开仍可通过该入口使用;本包提供菜单项,而不创建自己的头部按钮。 ### 预期行为 -会话标题栏、文件树标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。这些标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。它们都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。文件树打开按钮以 `absolutePath` 传入的显示目录为目标,并与会话头部共享应用选择和启动状态。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 +会话头部把**在应用中打开**放在下载之后、反馈之前的独立分隔组。会话行更多、行右键及标题右键菜单把它放在复制 Session ID 之后、归档之前。只有一个应用时仍保留子菜单。文件树与文档标题栏保留高 24px、圆角 9px 的纯图标分体按钮。这些按钮的悬停提示显示默认应用或定位动作,其菜单保留默认标记。所有应用图标加载失败时均使用原生打开图标。文件树按钮以所属组件传入的 `absolutePath` 为目标,并与会话菜单共享应用选择及启动占用状态。目录选择仅在启动成功后持久化到 `dsh.open-in-app.choice`;原选择不可用时回退到首个符合条件的应用。目录启动忙碌时,禁用子菜单父项和全部应用行。 在 iOS、Android 手机和平板上(包括已安装的 PWA),原生打开获得确认后显示「已在其它设备打开」,文件定位则显示「已在其它设备显示文件位置」。电脑端成功时不提示,包括窄窗口浏览器与已安装的桌面 PWA。Host 拒绝请求或无法连通时,所有客户端都会收到失败 toast。成功表示 Host 已接受原生操作,不代表目标应用已完成文件加载。反馈复用现有移动设备判定,不依据窗口宽度或安装模式。 -**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令;其结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 +**在本地打开**快捷键捕获主会话目录及已记住的应用。有效绑定继续显示在文件树打开按钮的 tooltip 和 `aria-keyshortcuts` 中;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面且该会话具有可打开目录与已安装应用时才执行命令。启动进行中会阻止命令;结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享控制器的启动状态,因此重复操作不会启动两次,也不会在启动期间更改已记住的选择。 文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 -工作区右键菜单通过与会话标题相同的目录应用列表打开被点击的文件夹。文件树目录提供本机显示位置和复制文件夹路径,不提供文件预览或保存。 +工作区右键菜单通过共享的目录应用列表定位被点击的文件夹。文件树目录提供原生定位与复制文件夹路径,不提供文件预览或保存。 -右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。右键原生操作在菜单关闭后,通过本功能的浮层 toast 沿用移动端与电脑端反馈规则。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。 +右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话子菜单解析被捕获会话 ID 的目录,不使用主视图,并观察应用、操作、会话列表及语言变化。原生操作在菜单关闭或目标切换后仍通过本功能的全局 toast 报告,不产生第二个注册表提示。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及完成提示,包括通过提示定位保存目标。 文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。 @@ -55,7 +57,7 @@ kind: "package-reference"
实现内幕——点击展开 -两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与等待状态。适配器将原生操作和传输失败解析为具名结果;被忽略或取消的请求返回跳过结果,不作播报。 +[`session-open-with.ts`](src/client/session-open-with.ts) 为共享右键菜单包声明的 `session` 和 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。 目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 @@ -67,7 +69,7 @@ kind: "package-reference" ## 进一步探索 - [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。 -- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。 +- [ui-conversation](../ui-conversation/README.zh.md)——持有共享更多按钮的会话头部。 - [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.zh.md)——声明文件控件所占头部与空态子 slot 的文档预览。 - [ui-deliverables](../ui-deliverables/README.zh.md)——交付卡片,仍通过自己的路由打开声明过的文件。 - [Web client 架构](../../../docs/subsystems/web-client.zh.md)——浏览器插件行如何加载并注册 slot。 @@ -77,7 +79,7 @@ kind: "package-reference" ## 模型体验 -无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。 +无。这些打开控件与菜单是浏览器界面;不会进入模型请求。 #### KV Cache 影响 diff --git a/packages/client/ui-open-in-app/locale/zh.json b/packages/client/ui-open-in-app/locale/zh.json index 078d8a6924..d167d8c088 100644 --- a/packages/client/ui-open-in-app/locale/zh.json +++ b/packages/client/ui-open-in-app/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "外部应用打开界面", - "description": "在会话和文档预览中提供「打开方式」入口。" + "description": "在应用中打开会话目录或预览文件。" } } diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 58b7c79d3a..8e12ef41e6 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-open-in-app", - "description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file", - "version": "0.2.2-alpha.3", + "description": "Session application submenus and native opening controls for directories and previewed files", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,7 +30,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index ceb84fc611..cdbf77d3b6 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -7,7 +7,7 @@ import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/cli import { NS } from './locales.ts' import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' -/** Browser operations and state shared by the Session header and file tree contributions. */ +/** Directory operations and state shared with Session application submenus. */ export interface OpenInAppActionInjected extends OpenTargetReporter { hooks: { openInAppApps: ObservableSnapshot diff --git a/packages/client/ui-open-in-app/src/client/context-menus.tsx b/packages/client/ui-open-in-app/src/client/context-menus.tsx index 2f6fb14295..5810a3aab7 100644 --- a/packages/client/ui-open-in-app/src/client/context-menus.tsx +++ b/packages/client/ui-open-in-app/src/client/context-menus.tsx @@ -1,4 +1,5 @@ /** Native path and URL menu contributions, independent of their trigger components. */ +import { useState } from 'react' import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { Context } from '@deepseek-ai/cordis' @@ -141,5 +142,8 @@ export function registerContextMenus( /** @param props - icon metadata captured with the resolved action. @returns Application image or the native-open glyph. */ export function ContextMenuApplicationIcon({ icon }: Pick, 'icon'>) { - return icon.src ?
: + const [failedSource, setFailedSource] = useState() + return icon.src && icon.src !== failedSource + ? { setFailedSource(icon.src) }} /> + : } diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts index c2e25085e8..bd43f8af7e 100644 --- a/packages/client/ui-open-in-app/src/client/controller.ts +++ b/packages/client/ui-open-in-app/src/client/controller.ts @@ -1,4 +1,4 @@ -/** Browser availability/choice state and the launch carrier for the split button. */ +/** Shared directory application catalog, preference, and launch carrier for controls and menus. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { @@ -20,7 +20,7 @@ type Fetch = (input: string | URL, init?: RequestInit) => Promise /** * Owns the once-per-page availability read, the persisted last choice, and * the launch POST. Availability and choice publish through uSES-safe sources - * so every Session header shares one truth. + * so Session menus and directory controls share the same catalog and choice. */ export class OpenInAppController { /** Installed app ids in host menu order; null until the host answered. */ @@ -107,9 +107,8 @@ export class OpenInAppController { const payload = await response.json() as OpenInAppAppsPayload if (Array.isArray(payload.apps)) apps = payload.apps.filter(id => typeof id === 'string') } - } catch { - // Swallows network failures: an unreachable host reads as no apps, and - // the header simply shows no button rather than a broken one. + } catch (_error) { + // An unreachable Host supplies no directory openers in menus or controls. } this.apps.set(apps) } diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 49e5fe25e4..e706f9eb25 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -7,8 +7,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' * file defaults and application lists come from the serving Host desktop. */ -import { createElement } from 'react' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -23,8 +21,8 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/remote' import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared' import { OpenInAppController } from './controller.ts' -import { registerSessionReveal } from './session-reveal.tsx' -import { OpenInAppAction, type OpenInAppActionInjected, type OpenInAppActionProps } from './OpenInAppAction.tsx' +import { registerSessionOpenWith } from './session-open-with.ts' +import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx' import { OpenInAppPathController } from './open-path.ts' import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx' import { FileRouteAction } from './FileRouteAction.tsx' @@ -35,7 +33,7 @@ import { createOpenInAppToastSource, OpenInAppToast } from './open-toast.tsx' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Session-header "open workspace in application" copy. */ + /** Native application menu and control copy. */ 'open-in-app': OpenInAppKey } } @@ -70,7 +68,7 @@ export function apply(ctx: ClientContext): void { yield ctx.slots.register({ name: 'contextMenu.icon', key: 'open-in-app.application' }, ContextMenuApplicationIcon) }) registerContextMenus(ctx, paths, t, controller, announcement.report) - registerSessionReveal(ctx, controller, t, announcement.report) + registerSessionOpenWith(ctx, controller, t, announcement.report) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'open-in-app.toast', locale: NS, inject: () => ({ hooks: announcement.hooks, dismiss: announcement.dismiss }), @@ -113,17 +111,6 @@ export function apply(ctx: ClientContext): void { choose: (appId) => { controller.choose(appId) }, iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`, }) - function SessionOpenInAppAction( - props: PropsRuntime<'conversation.session.header.utilities'> & Omit, - ) { - const { sessionId, useSessions } = props - const cwd = useSessions(state => state.byId[sessionId]?.cwd) - return cwd ? createElement(OpenInAppAction, { ...props, absolutePath: cwd }) : null - } - ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ - name: 'conversation.session.header.utilities', - id: 'open-in-app', order: -10, locale: NS, inject: directoryInjected, - }, SessionOpenInAppAction)) ctx.slots.inject('sidebar.right.tab.files.actions', () => ctx.slots.register({ name: 'sidebar.right.tab.files.actions', id: 'open-in-app', locale: NS, inject: directoryInjected, diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index bd87413a4e..d4d3c48548 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -39,6 +39,7 @@ const PRODUCT_NAMES = { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'menu.openIn': '在应用中打开', 'menu.openInSidebar': '在侧边栏打开', 'menu.openInFloat': '在浮窗中打开', 'menu.finder': '在 Finder 中显示', @@ -73,6 +74,7 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { + 'menu.openIn': 'Open in', 'menu.openInSidebar': 'Open in sidebar', 'menu.openInFloat': 'Open in floating panel', 'menu.finder': 'Show in Finder', @@ -110,6 +112,7 @@ export type OpenInAppKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { + 'menu.openIn': 'アプリケーションで開く', 'menu.openInSidebar': 'サイドバーで開く', 'menu.openInFloat': 'フローティングパネルで開く', 'menu.finder': 'Finder に表示', @@ -144,6 +147,7 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + 'menu.openIn': 'Abrir con', 'menu.openInSidebar': 'Abrir en la barra lateral', 'menu.openInFloat': 'Abrir en un panel flotante', 'menu.finder': 'Mostrar en Finder', @@ -177,6 +181,7 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + 'menu.openIn': 'Ouvrir avec', 'menu.openInSidebar': 'Ouvrir dans la barre latérale', 'menu.openInFloat': 'Ouvrir dans un panneau flottant', 'menu.finder': 'Afficher dans le Finder', @@ -210,6 +215,7 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + 'menu.openIn': 'Öffnen mit', 'menu.openInSidebar': 'In der Seitenleiste öffnen', 'menu.openInFloat': 'In schwebendem Panel öffnen', 'menu.finder': 'Im Finder anzeigen', @@ -243,6 +249,7 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + 'menu.openIn': 'Открыть с помощью', 'menu.openInSidebar': 'Открыть на боковой панели', 'menu.openInFloat': 'Открыть на плавающей панели', 'menu.finder': 'Показать в Finder', @@ -276,6 +283,7 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'menu.openIn': 'Abrir com', 'menu.openInSidebar': 'Abrir na barra lateral', 'menu.openInFloat': 'Abrir em painel flutuante', 'menu.finder': 'Mostrar no Finder', diff --git a/packages/client/ui-open-in-app/src/client/session-open-with.ts b/packages/client/ui-open-in-app/src/client/session-open-with.ts new file mode 100644 index 0000000000..7119958e9e --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/session-open-with.ts @@ -0,0 +1,51 @@ +/** Application submenus for captured Sessions, shared by header and row menus. */ +import type { Context } from '@deepseek-ai/cordis' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ContextMenuTargetMap, MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared' +import { APP_LABEL_KEY } from './applications.ts' +import type { OpenInAppController } from './controller.ts' +import type { OpenInAppToastSource } from './open-toast.tsx' + +/** + * Share directory availability, successful choices, and launch feedback with the file-tree control. + * @param ctx - owning plugin context. + * @param controller - directory application catalog and launcher. + * @param t - localized action labels. + * @param report - app-wide native operation feedback; launch failures resolve without registry feedback. + */ +export function registerSessionOpenWith( + ctx: Context, + controller: OpenInAppController, + t: PropsLocale<'open-in-app'>['t'], + report: OpenInAppToastSource['report'], +): void { + const resolve = ({ sessionId }: ContextMenuTargetMap['sessionHeader']): readonly MenuAction[] => { + const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + const applications = (controller.apps.getSnapshot() ?? []).flatMap((id) => { + const key = APP_LABEL_KEY[id] + return key === undefined ? [] : [{ id, name: t(key) }] + }) + if (!directory || applications.length === 0) return [] + const disabled = controller.operation.getSnapshot().phase === 'busy' + return [{ + id: 'openWith', label: t('menu.openIn'), icon: { key: 'open-in-app.application' }, disabled, + children: applications.map(({ id, name }) => ({ + id: `app:${id}`, label: name, disabled, + icon: { key: 'open-in-app.application', src: `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${id}` }, + run: async () => { + const outcome = await controller.launch(id, directory) + if (outcome.kind === 'opened') controller.choose(id) + report(outcome) + }, + })), + }] + } + for (const target of ['session', 'sessionHeader'] as const) { + ctx.effect(() => ctx.contextMenus.register({ + id: 'open-in-app.openWith', target, order: 500, + observe: () => [controller.apps, controller.operation, ctx.sessions.list, ctx.locale], + resolve: value => resolve(value).map(action => target === 'sessionHeader' ? { ...action, separatorBefore: true } : action), + }), `open-in-app: ${target} directory menu`) + } +} diff --git a/packages/client/ui-open-in-app/src/client/session-reveal.tsx b/packages/client/ui-open-in-app/src/client/session-reveal.tsx deleted file mode 100644 index 0dcf0c3a86..0000000000 --- a/packages/client/ui-open-in-app/src/client/session-reveal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** Native directory reveal contributed to the captured Session's structured menu. */ -import type { Context } from '@deepseek-ai/cordis' -import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { OpenInAppController } from './controller.ts' -import type { OpenInAppToastSource } from './open-toast.tsx' - -/** - * Share directory availability and launch feedback with the existing application controls. - * @param ctx - owning plugin context. - * @param controller - directory application catalog and launcher. - * @param t - localized action and failure labels. - * @param report - native operation feedback shared with the application controls. - */ -export function registerSessionReveal( - ctx: Context, - controller: OpenInAppController, - t: PropsLocale<'open-in-app'>['t'], - report: OpenInAppToastSource['report'], -): void { - ctx.effect(() => ctx.contextMenus.register({ - id: 'open-in-app.reveal', target: 'session', order: 500, - observe: () => [controller.apps, ctx.sessions.list, ctx.locale], - resolve: ({ sessionId }) => { - const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd - const applications = controller.apps.getSnapshot() - const manager = applications?.find(id => ['finder', 'explorer', 'filemanager'].includes(id)) - return [{ - id: 'reveal', label: t(applications?.includes('finder') ? 'menu.finder' : 'menu.reveal'), icon: { key: 'open-in-app.reveal' }, - disabled: !directory || manager === undefined, failureLabel: t('path.revealError'), - ...(directory && manager !== undefined ? { run: async () => { - const outcome = await controller.launch(manager, directory) - report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome) - } } : {}), - }] - }, - }), 'open-in-app: Session directory menu') -} diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index e0457da73f..ca27f69e50 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -10,7 +10,6 @@ import { isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives' * proving removal (HMR safety) and the injected controller faces. */ -import type { PropsRuntime, SlotComponent } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -118,6 +117,11 @@ describe('open-in-app browser half', () => { it('renders captured application images and the default glyph without an image URL', () => { const view = render(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/captured.png' } })) expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/captured.png') + fireEvent.error(view.container.querySelector('img')!) + expect(view.container.querySelector('img')).toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() + view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/new.png' } })) + expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/new.png') view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application' } })) expect(view.container.querySelector('img')).toBeNull() expect(view.container.querySelector('svg')).not.toBeNull() @@ -141,14 +145,14 @@ describe('open-in-app browser half', () => { 'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.float', 'open-in-app.application', ]) expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0) - await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) }) - menu.select('open-in-app.reveal/reveal') + await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false }) }) + menu.select(menu.source.getSnapshot()[0]!.children![0]!.id) menu.dispose() - const face: Partial = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!() + const face: Partial = ctx.slots.entries('sidebar.right.tab.files.actions')[0]!.inject!() await vi.waitFor(() => { expect(face.hooks?.openInAppLaunch.getSnapshot().phase).toBe(success ? 'idle' : 'error') }) expect(toast.hooks.toast.getSnapshot()).toEqual(success ? mobile ? { kind: 'opened', seq: 1 } : null - : { kind: 'failed', failure: 'revealError', seq: 1 }) + : { kind: 'failed', failure: 'openError', seq: 1 }) expect(notify).not.toHaveBeenCalled() expect(fetcher).toHaveBeenCalledWith('open-in-app/open', expect.objectContaining({ body: JSON.stringify({ app: 'finder', path: '/captured' }), @@ -196,7 +200,7 @@ describe('open-in-app browser half', () => { const select = (cwd: string) => { list.set({ ...list.getSnapshot(), ids: [id], byId: { [id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } }, } }) } - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const face = (entry?.inject as unknown as () => OpenInAppActionInjected)() await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor']) }) select('/first') @@ -281,7 +285,7 @@ describe('open-in-app browser half', () => { list.set({ ...list.getSnapshot(), ids: [id], byId: { [id]: { id, displayTitle: 'Main', cwd: '/workspace', running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } }, } }) - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const face = (entry?.inject as unknown as () => OpenInAppActionInjected)() await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder']) }) const command = commands.get('workspace.openLocal')! @@ -343,30 +347,13 @@ describe('open-in-app browser half', () => { expect(ctx.slots.entries('shell.overlay')).toHaveLength(0) }) - it('adapts the Session directory, shares the controller with the file tree, and removes both on teardown', async () => { + it('keeps the file-tree control without an independent Session header opener and removes it on teardown', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 }))) - const { ctx, fiber, list } = await bench() - try { - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] - const files = ctx.slots.entries('sidebar.right.tab.files.actions')[0] - expect(files?.component).toBe(OpenInAppAction) - expect(files?.inject).toBe(entry?.inject) - expect(entry?.options).toMatchObject({ id: 'open-in-app' }) - const header = entry?.component as SlotComponent, 'sessionId' | 'useSessions' - >> - const id = 'main' as SessionId - const props = { sessionId: id, useSessions: (select: (state: SessionListState) => T) => select(list.getSnapshot()) } - expect(header(props)).toBeNull() - for (const cwd of ['', '/first', '/second']) { - list.set({ ...list.getSnapshot(), ids: [id], byId: { - [id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: {} }, - } }) - if (cwd === '') expect(header(props)).toBeNull() - else expect(header(props)).toMatchObject({ type: OpenInAppAction, props: { absolutePath: cwd } }) - } - } finally { await fiber.dispose() } - expect(headerEntryIds(ctx)).not.toContain('open-in-app') + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toEqual([]) + expect(ctx.slots.entries('sidebar.right.tab.files.actions')[0]?.component).toBe(OpenInAppAction) + await fiber.dispose() + expect(headerEntryIds(ctx)).toEqual([]) expect(ctx.slots.entries('sidebar.right.tab.files.actions')).toHaveLength(0) }) @@ -381,7 +368,7 @@ describe('open-in-app browser half', () => { }) vi.stubGlobal('fetch', fetcher) const { ctx, fiber } = await bench() - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const injected: Partial | undefined = entry?.inject?.() if (injected?.hooks === undefined || injected.iconUrl === undefined || injected.choose === undefined || injected.launch === undefined || injected.report === undefined) { @@ -417,7 +404,7 @@ describe('open-in-app browser half', () => { return new Response('', { status: 502 }) })) const { ctx, fiber } = await bench() - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const injected: Partial | undefined = entry?.inject?.() if (injected?.hooks === undefined || injected.launch === undefined) throw new Error('expected the injected launch action') const { hooks } = injected diff --git a/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts new file mode 100644 index 0000000000..b8db4a0154 --- /dev/null +++ b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts @@ -0,0 +1,124 @@ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { OpenInAppController } from '../src/client/controller.ts' +import { registerSessionOpenWith } from '../src/client/session-open-with.ts' +import { en, zh, NS } from '../src/client/locales.ts' + +function bench(apps: readonly string[] | null = ['finder', 'cursor'], cwd: string | undefined = '/captured') { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + locale.register(NS, { en, zh }); locale.setLocale('en') + const failed = vi.fn(), report = vi.fn() + const registry = new MenuRegistry(failed) + onTestFinished(() => { registry.dispose() }) + ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never) + const sessionId = SessionId('captured'), mainId = SessionId('main') + const row = { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} } + const list = createSnapshotStore({ ids: [sessionId, mainId], byId: { + [sessionId]: row, [mainId]: { ...row, id: mainId, cwd: '/main', retainedBy: { mainView: 1 } }, + }, phase: 'ready', projectionsBySession: {} }) + ctx.provide('sessions', { list } as never) + const gate = Promise.withResolvers() + const fetcher = vi.fn(() => gate.promise) + const controller = new OpenInAppController(fetcher) + controller.choice.set(''); controller.apps.set(apps) + const choose = vi.spyOn(controller, 'choose') + const fiber = ctx.plugin((scope) => { registerSessionOpenWith(scope, controller, locale.bind(NS), report) }) + const resolve = (kind: 'session' | 'sessionHeader' = 'session') => registry.resolve(kind === 'session' + ? { kind, value: { sessionId, displayTitle: 'Captured' } } + : { kind, value: { sessionId } }) + return { ctx, registry, locale, controller, list, row, sessionId, fetcher, gate, choose, report, failed, fiber, resolve } +} + +it.each([null, [], ['unlabelled']])('omits unavailable catalog %j from both menu targets', async (apps) => { + const b = bench(apps) + await b.fiber.await() + for (const kind of ['session', 'sessionHeader'] as const) expect(b.resolve(kind).source.getSnapshot()).toEqual([]) +}) + +it('omits unknown directories and follows metadata, locale and applications without labeling preferences', async () => { + const b = bench() + await b.fiber.await() + b.list.set({ ...b.list.getSnapshot(), byId: {} }) + const menu = b.resolve(), header = b.resolve('sessionHeader') + expect(menu.source.getSnapshot()).toEqual([]) + b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: b.row } }) + expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false }) + expect(menu.source.getSnapshot()[0]?.separatorBefore).toBeUndefined() + expect(header.source.getSnapshot()[0]?.separatorBefore).toBe(true) + expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor']) + b.controller.choose('cursor') + expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor']) + b.locale.setLocale('zh') + expect(menu.source.getSnapshot()[0]?.label).toBe('在应用中打开') + expect(header.source.getSnapshot()[0]?.children?.[1]?.label).toBe('Cursor') + b.controller.apps.set(['cursor', 'unknown', 'finder']) + expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Cursor', '访达']) + b.controller.apps.set(['finder']) + expect(menu.source.getSnapshot()[0]?.children).toHaveLength(1) + expect(menu.source.getSnapshot()[0]?.children?.[0]).toMatchObject({ + label: '访达', icon: { key: 'open-in-app.application', src: 'open-in-app/icon/finder' }, + }) + b.controller.apps.set([]) + expect(menu.source.getSnapshot()).toEqual([]) + expect(header.source.getSnapshot()).toEqual([]) +}) + +it.each(['session', 'sessionHeader'] as const)('launches the captured %s directory, disables all openers, and remembers only after success', async (kind) => { + const b = bench() + await b.fiber.await() + const menu = b.resolve(kind), observing = b.resolve(kind === 'session' ? 'sessionHeader' : 'session') + const action = menu.source.getSnapshot()[0]!.children![1]! + menu.select(action.id) + expect(b.fetcher).toHaveBeenCalledExactlyOnceWith('open-in-app/open', expect.objectContaining({ body: JSON.stringify({ app: 'cursor', path: '/captured' }) })) + expect(b.choose).not.toHaveBeenCalled() + expect(observing.source.getSnapshot()[0]).toMatchObject({ disabled: true }) + expect(observing.source.getSnapshot()[0]?.children?.every(row => row.disabled)).toBe(true) + observing.select(observing.source.getSnapshot()[0]!.children![0]!.id) + expect(b.fetcher).toHaveBeenCalledOnce() + b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: { ...b.row, cwd: '/changed' } } }) + menu.dispose() + b.gate.resolve(new Response()) + await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith({ kind: 'opened' }) }) + expect(b.choose).toHaveBeenCalledExactlyOnceWith('cursor') + expect(b.controller.choice.getSnapshot()).toBe('cursor') + expect(observing.source.getSnapshot()[0]?.disabled).toBe(false) + expect(b.failed).not.toHaveBeenCalled() +}) + +it.each([{ kind: 'failed', failure: 'openError' }, { kind: 'skipped' }] as const)('does not remember a %j launch or duplicate its feedback', async (outcome) => { + const b = bench() + await b.fiber.await() + vi.spyOn(b.controller, 'launch').mockResolvedValue(outcome) + const menu = b.resolve() + menu.select(menu.source.getSnapshot()[0]!.children![1]!.id) + await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith(outcome) }) + expect(b.choose).not.toHaveBeenCalled() + expect(b.controller.choice.getSnapshot()).toBe('') + expect(b.failed).not.toHaveBeenCalled() +}) + +it('withdraws both registrations and their observers on unload and reinstalls without duplicates', async () => { + const b = bench() + await b.fiber.await() + const menu = b.resolve(), header = b.resolve('sessionHeader') + const id = menu.source.getSnapshot()[0]!.children![0]!.id + await b.fiber.dispose() + expect(menu.source.getSnapshot()).toEqual([]) + expect(header.source.getSnapshot()).toEqual([]) + menu.select(id) + expect(b.fetcher).not.toHaveBeenCalled() + b.controller.apps.set(['cursor']) + expect(b.resolve().source.getSnapshot()).toEqual([]) + const replacement = b.ctx.plugin((scope) => { registerSessionOpenWith(scope, b.controller, b.locale.bind(NS), b.report) }) + await replacement.await() + expect(b.resolve().source.getSnapshot()).toHaveLength(1) + expect(b.resolve('sessionHeader').source.getSnapshot()).toHaveLength(1) +}) diff --git a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts deleted file mode 100644 index 1638722be5..0000000000 --- a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { expect, it, onTestFinished, vi } from 'vitest' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' -import { SessionId } from '@deepseek-ai/dsh-session/types' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { OpenInAppController } from '../src/client/controller.ts' -import { registerSessionReveal } from '../src/client/session-reveal.tsx' -import { en, zh, NS } from '../src/client/locales.ts' - -it.each([ - [['finder'], '/workspace', 'Show in Finder', false], - [['explorer'], '/workspace', 'Show in folder', false], - [['filemanager'], '/workspace', 'Show in folder', false], - [['vscode'], '/workspace', 'Show in folder', true], - [null, '/workspace', 'Show in folder', true], - [['finder'], undefined, 'Show in Finder', true], -] as const)('reveals the captured directory with catalog %j and path %s', async (apps, cwd, label, disabled) => { - const ctx = new Context() - onTestFinished(() => ctx.fiber.dispose()) - const locale = new LocaleRuntime(ctx) - ctx.provide('locale', locale) - locale.register(NS, { en, zh }); locale.setLocale('en') - const registry = new MenuRegistry(vi.fn()) - onTestFinished(() => { registry.dispose() }) - ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never) - const sessionId = SessionId('captured') - const list = createSnapshotStore({ ids: [sessionId], byId: { - [sessionId]: { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} }, - }, phase: 'ready', projectionsBySession: {} }) - ctx.provide('sessions', { list } as never) - const controller = new OpenInAppController() - controller.apps.set(apps) - const launch = vi.spyOn(controller, 'launch').mockResolvedValue({ kind: 'opened' }) - const report = vi.fn() - registerSessionReveal(ctx, controller, locale.bind(NS), report) - const menu = registry.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Captured' } }) - expect(menu.source.getSnapshot()[0]).toMatchObject({ label, disabled }) - menu.select('open-in-app.reveal/reveal') - if (disabled) { - expect(launch).not.toHaveBeenCalled() - expect(report).not.toHaveBeenCalled() - } else { - expect(launch).toHaveBeenCalledWith(apps?.[0], cwd) - await vi.waitFor(() => { expect(report).toHaveBeenCalledWith({ kind: 'opened' }) }) - } -}) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 84fd5d6129..0b49d98e4c 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -73,6 +73,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index a6bca22e78..4c87c9a9f8 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -77,6 +77,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index f2e8d66936..9e8a73eb51 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -1,7 +1,7 @@ .cards { display: flex; flex-direction: column; gap: 10px; } .card { - --plan-card-fill: var(--dsw-static-neutral-50); - --plan-card-hover: var(--dsw-static-neutral-100); + --plan-card-fill: var(--dsw-alias-card-fill); + --plan-card-hover: var(--dsw-alias-card-fill-hover); box-sizing: border-box; display: flex; align-items: center; @@ -20,13 +20,11 @@ cursor: pointer; transition: background-color 120ms ease; } -:global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); } @media (hover: hover) { .card:hover { background: var(--plan-card-hover); } } .card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 3px; } -.cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); } -:global(body[data-ds-dark-theme]) .cardIcon { background: color-mix(in srgb, var(--dsw-static-neutral-00) 5%, transparent); } +.cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-card-tile-fill); } .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } .cardDescription { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 16px; } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index d09c4925a2..7ac4ca91dc 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,14 +15,14 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 01ba764b51f010e8 - zh: e43a158a5b09d987 + en: 0c76e6c00cc7388d + zh: e56ddb7b78111b73 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: 1fc6c5c2a54d8db4 zh: 9e53f5164e2b7161 -/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/updating-a-bundle: + en: 2a44a378364912db + zh: 8c514ce17469747f /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: 390dc85aceac033a zh: 746ceae45f70dd33 @@ -36,8 +36,8 @@ en: d5e3cad5c31cae59 zh: 6dc38f09b410d247 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: - en: fc469bab4fbd6028 - zh: 8f5a04e1b9a46471 + en: 99c06790c2163746 + zh: daa87ad1ce9a76ae /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 203168fc87e939bd zh: 72d0ccd2c54f3f44 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 8aaa4a3e74..412df1b9af 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -45,7 +45,7 @@ The first list read shows a short group-heading placeholder and four skeleton ca A bundle unavailable on the DSH Host operating system shows the reason on its card and details page. Its disabled switch prevents enabling it; an already selected bundle can still be switched off. [Plugin Manager](../../boot/plugin-manager/README.md#use-this-package) enforces the same restriction for direct requests. -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. +The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. It reads the list again, then checks every bundle installed from a registry 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 @@ -68,13 +68,13 @@ 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 +### Updating a 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. +A third-party bundle installed from a registry, which the Host marks `update: 'registry'`, 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; a bundle installed from Git updates from a tab such as **Discover**. 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. +**Update** opens a confirmation with the installed and target versions, the registry it installs from, or the host and path of a Git target's repository, 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 @@ -116,7 +116,7 @@ Component lists and detail pages show current startup diagnostics from the Host ### Page tabs -Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here. +Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })`, `update({ packageName, version, spec })`, and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. `update` is for a newer Git version of a bundle the Host marks `update: 'git'`, from the repository directory that bundle came from: it opens the **Update** confirmation from the version the list holds, which names the repository and runs the same steps as a registry update with the pinned spec, and does nothing before the list holds the bundle or while another update runs. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here. Access: the Plugins entry, page, refresh notice, Host change subscriptions, and `pluginNavigation` exist only while the current Peer may call `pluginManager/listBundles`; each later grant re-reads a list already loaded, and `pluginChanges` receives no updates while access is withdrawn. The owner always has access. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index a89ed56c46..8d945eea5b 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" 组合包不支持 DSH Host 的操作系统时,卡片和详情页会显示原因,关闭状态的开关不可启用;已选中的组合包仍可关闭。[插件管理器](../../boot/plugin-manager/README.zh.md#use-this-package)对直接请求执行同一限制。 -**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。它重新读取列表,再依次检查每个可更新组合包的新版本;读取失败时不做检查。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取和这些检查结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择该按钮即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 +**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。它重新读取列表,再依次检查每个从注册表安装的组合包的新版本;读取失败时不做检查。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取和这些检查结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择该按钮即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 ### 安装一个组合包 @@ -68,13 +68,13 @@ 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 标为 `update: 'registry'` 的第三方组合包,在其页面的行列表下方有 **版本** 区块。打开页面时向 Host 询问一次[它的新版本](../../boot/plugin-manager/README.zh.md#use-this-package);**重新检查** 再问一次。区块显示一行摘要:正在检查;已是最新及上次检查时间;可更新到声明组合包、接受当前 DSH、未弃用且非预发布的最新版本;新版本需要更新 DSH 及其所需的 DSH peer;或检查失败、最后失败的注册表及 **重试**。有新版本的摘要可展开为每个版本一行,从新到旧,带 dist-tag、预发布与已弃用标签、无法安装的原因、发布日期,以及可安装版本的 **更新**;列表以已安装版本结尾。其下显示作答的注册表,前面的注册表失败时标为 **备选源**,旁边是检查时间和 **显示预发布版本**。检查失败时可通过 **详情** 展开问过的每个注册表、其角色、失败类型和 pnpm 的原文。从链接、路径、git 或压缩包安装的组合包以及官方组合包不显示版本区块;从 Git 安装的组合包通过 **发现** 等标签页更新。不会提供更旧的版本。 检查结束后,列表工具栏说明有几个插件可更新、全部已是最新,或部分检查失败,有更新的卡片显示 **可更新**。详情页头部增加带目标版本的 **更新到**。 -**更新** 打开确认框,显示已安装版本与目标版本、安装来源,以及安装失败时会保留或重新安装已安装版本。Host 运行更新期间,对话框列出它通告的步骤并保持打开。结果说明新版本已开始运行、需要重启才生效及 Host 给出的原因、目标版本在任何改动前被拒绝、移除已安装版本失败、目标版本未能安装而已安装版本保持不变、更新失败且已重新安装原版本,或重新安装也失败、插件未安装;失败的运行可展开 pnpm 输出。答复丢失时说明结果未知,并重新读取列表。 +**更新** 打开确认框,显示已安装版本与目标版本、安装来源的注册表或 Git 目标仓库的主机与路径,以及安装失败时会保留或重新安装已安装版本。Host 运行更新期间,对话框列出它通告的步骤并保持打开。结果说明新版本已开始运行、需要重启才生效及 Host 给出的原因、目标版本在任何改动前被拒绝、移除已安装版本失败、目标版本未能安装而已安装版本保持不变、更新失败且已重新安装原版本,或重新安装也失败、插件未安装;失败的运行可展开 pnpm 输出。答复丢失时说明结果未知,并重新读取列表。 ### 切换一个组合包 @@ -116,7 +116,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ ### 页面标签页 -其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。 +其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })`、`update({ packageName, version, spec })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。`update` 用于 Host 标为 `update: 'git'` 的组合包、来自其来源仓库目录的较新 Git 版本:它以列表中记录的版本为原版本打开**更新**确认框,该确认框显示仓库,并以固定的 spec 执行与注册表更新相同的步骤;列表尚未包含该组合包或另一个更新正在运行时,它不做任何事。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。 访问控制:插件入口、页面、刷新提示、Host 变更订阅与 `pluginNavigation` 只在当前 Peer 可以调用 `pluginManager/listBundles` 时存在;之后每次授权都会重读已加载过的列表,撤权期间 `pluginChanges` 不更新。owner 始终有权限。 diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 9fa84251f2..633f76347e 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-manager", "description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -81,15 +81,19 @@ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "dependencies": { "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3", + "zod": "4.6.6", "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } 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 1b6e2b58e3..b73c906144 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -290,7 +290,9 @@ .groupMore { flex: none; gap: 4px; - padding-inline-end: 0; + /* Like .card, the hover surface reaches 8px past the trailing content edge. */ + margin-inline-end: -8px; + padding: 0 8px; font-size: 14px; line-height: 22px; font-weight: 400; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 71c63c3db7..41df8e96a2 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -511,9 +511,9 @@ 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. */ +/** The list toolbar's sentence about the last update check of every registry 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]) + const checks = packages.filter(pkg => pkg.update === 'registry').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' } @@ -1731,6 +1731,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{renderSlot('plugins.tab', { install: props.installFrom, + update: props.openGitUpdate, openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) }, }, { only: tab })}
diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index f5908227a4..eadd359408 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -65,7 +65,7 @@ export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' export type { PluginManagerLocaleKey } from './locales.ts' export type { - ConfigPageForm, PluginActivationOwnerProps, PluginAddActionsProps, PluginTabInstall, PluginTabProps, + ConfigPageForm, PluginActivationOwnerProps, PluginAddActionsProps, PluginTabInstall, PluginTabProps, PluginTabUpdate, PluginConfigViewProps, PluginDetailProps, PluginPackageRef, PluginRowRef, PluginsSubject, } from './slot-contract.ts' diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 6b31b7ffa8..b9847473c5 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -164,7 +164,7 @@ export const zh = { updateRunning: '正在更新', updateStepChecking: '检查 {version}', updateStepRemoving: '停止并移除 {version}', - updateStepInstalling: '从 {registry} 安装 {version}', + updateStepInstalling: '从 {source} 安装 {version}', updateStepRestoring: '重新安装 {version}', updateDone: '已更新到 {version}', updateDoneDetail: '新版本已经开始运行。', @@ -290,6 +290,7 @@ export const zh = { reasonUnsupportedPlatform: '此插件不支持运行 DSH 的主机操作系统', reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。', reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。', + reasonUnexpectedPackage: '插件来源安装了与所请求不同的包或版本,已撤销这次安装。请向插件发布者或插件索引维护者报告此问题。', reasonOperationError: 'Host 报告了一个错误', detailTitle: '详细介绍', detailLoading: '正在读取详细介绍', @@ -462,7 +463,7 @@ export const en = { updateRunning: 'Updating', updateStepChecking: 'Check {version}', updateStepRemoving: 'Stop and remove {version}', - updateStepInstalling: 'Install {version} from {registry}', + updateStepInstalling: 'Install {version} from {source}', updateStepRestoring: 'Install {version} again', updateDone: 'Updated to {version}', updateDoneDetail: 'The new version is running.', @@ -588,6 +589,7 @@ export const en = { reasonUnsupportedPlatform: 'This plugin does not support the operating system running DSH.', reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.', reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.', + reasonUnexpectedPackage: 'The plugin source installed another package or version than requested; the installation was undone. Report it to the plugin\'s publisher or the index maintainer.', reasonOperationError: 'The Host reported an error.', detailTitle: 'Details', detailLoading: 'Reading details', @@ -758,7 +760,7 @@ export const ja = { 'updateRunning': 'アップデート中', 'updateStepChecking': '{version}を確認', 'updateStepRemoving': '{version}を停止して削除', - 'updateStepInstalling': '{registry}から{version}をインストール', + 'updateStepInstalling': '{source}から{version}をインストール', 'updateStepRestoring': '{version}を再インストール', 'updateDone': '{version}にアップデートしました', 'updateDoneDetail': '新しいバージョンが実行中です。', @@ -883,6 +885,7 @@ export const ja = { 'reasonIncompatibleVersionUnnamed': 'このプラグインは実行中の DSH バージョンと互換性がありません。実行するとクラッシュやデータ損失を引き起こす可能性があります。', 'reasonIncompatibleInstall': 'この DSH と互換性のあるプラグインバージョンをインストールしてください。', 'reasonIncompatibleInstalled': 'アンインストールして、この DSH と互換性のあるバージョンをインストールしてください。', + 'reasonUnexpectedPackage': 'プラグインのソースが要求とは異なるパッケージまたはバージョンをインストールしました。インストールを元に戻しました。プラグインの公開者またはプラグインインデックスの管理者に報告してください。', 'reasonOperationError': 'Host がエラーを報告しました。', 'detailTitle': '詳細', 'detailLoading': '詳細を読み込み中', @@ -1053,7 +1056,7 @@ export const es = { updateRunning: 'Actualizando', updateStepChecking: 'Comprobar {version}', updateStepRemoving: 'Detener y eliminar {version}', - updateStepInstalling: 'Instale {version} desde {registry}', + updateStepInstalling: 'Instale {version} desde {source}', updateStepRestoring: 'Instale {version} nuevamente', updateDone: 'Actualizado a {version}', updateDoneDetail: 'La nueva versión se está ejecutando.', @@ -1178,6 +1181,7 @@ export const es = { reasonIncompatibleVersionUnnamed: 'Este complemento es incompatible con la versión DSH en ejecución; ejecutarlo puede causar fallas o pérdida de datos.', reasonIncompatibleInstall: 'Instale una versión del complemento compatible con este DSH.', reasonIncompatibleInstalled: 'Desinstálalo e instala una versión compatible con este DSH.', + reasonUnexpectedPackage: 'La fuente del complemento instaló un paquete o una versión distintos de lo solicitado; se deshizo la instalación. Informe el problema al editor del complemento o al responsable del índice de complementos.', reasonOperationError: 'El anfitrión informó un error.', detailTitle: 'Detalles', detailLoading: 'Detalles de lectura', @@ -1348,7 +1352,7 @@ export const fr = { updateRunning: 'Mise à jour', updateStepChecking: 'Vérifiez {version}', updateStepRemoving: 'Arrêtez et supprimez {version}', - updateStepInstalling: 'Installez {version} à partir de {registry}', + updateStepInstalling: 'Installez {version} à partir de {source}', updateStepRestoring: 'Installez à nouveau {version}', updateDone: 'Mis à jour vers {version}', updateDoneDetail: "La nouvelle version est en cours d'exécution.", @@ -1473,6 +1477,7 @@ export const fr = { reasonIncompatibleVersionUnnamed: "Ce plugin est incompatible avec la version DSH en cours d'exécution ; son exécution peut provoquer des plantages ou une perte de données.", reasonIncompatibleInstall: 'Installez une version du plugin compatible avec ce DSH.', reasonIncompatibleInstalled: 'Désinstallez-le et installez une version compatible avec ce DSH.', + reasonUnexpectedPackage: "La source du plugin a installé un autre package ou une autre version que ceux demandés ; l'installation a été annulée. Signalez-le à l'éditeur du plugin ou au responsable de l'index de plugins.", reasonOperationError: "L'hôte a signalé une erreur.", detailTitle: 'Détails', detailLoading: 'Détails de lecture', @@ -1643,7 +1648,7 @@ export const de = { updateRunning: 'Aktualisierung', updateStepChecking: 'Überprüfen Sie {version}', updateStepRemoving: 'Stoppen und entfernen Sie {version}', - updateStepInstalling: 'Installieren Sie {version} von {registry}', + updateStepInstalling: 'Installieren Sie {version} von {source}', updateStepRestoring: 'Installieren Sie {version} erneut', updateDone: 'Aktualisiert auf {version}', updateDoneDetail: 'Die neue Version läuft.', @@ -1768,6 +1773,7 @@ export const de = { reasonIncompatibleVersionUnnamed: 'Dieses Plugin ist nicht mit der laufenden DSH-Version kompatibel. Die Ausführung kann zu Abstürzen oder Datenverlust führen.', reasonIncompatibleInstall: 'Installieren Sie eine mit diesem DSH kompatible Plugin-Version.', reasonIncompatibleInstalled: 'Deinstallieren Sie es und installieren Sie eine mit diesem DSH kompatible Version.', + reasonUnexpectedPackage: 'Die Plugin-Quelle hat ein anderes Paket oder eine andere Version als angefordert installiert; die Installation wurde rückgängig gemacht. Melden Sie dies dem Herausgeber des Plugins oder dem Betreuer des Plugin-Index.', reasonOperationError: 'Der Host hat einen Fehler gemeldet.', detailTitle: 'Details', detailLoading: 'Details lesen', @@ -1938,7 +1944,7 @@ export const ru = { updateRunning: 'Обновление', updateStepChecking: 'Проверьте {version}', updateStepRemoving: 'Остановите и удалите {version}.', - updateStepInstalling: 'Установите {version} из {registry}.', + updateStepInstalling: 'Установите {version} из {source}.', updateStepRestoring: 'Установите {version} еще раз.', updateDone: 'Обновлено до {version}.', updateDoneDetail: 'Новая версия работает.', @@ -2063,6 +2069,7 @@ export const ru = { reasonIncompatibleVersionUnnamed: 'Этот плагин несовместим с работающей версией DSH; его запуск может привести к сбоям или потере данных.', reasonIncompatibleInstall: 'Установите версию плагина, совместимую с этим DSH.', reasonIncompatibleInstalled: 'Удалите его и установите версию, совместимую с этим DSH.', + reasonUnexpectedPackage: 'Источник плагина установил не тот пакет или не ту версию, которые были запрошены; установка отменена. Сообщите об этом издателю плагина или сопровождающему индекса плагинов.', reasonOperationError: 'Хост сообщил об ошибке.', detailTitle: 'Подробности', detailLoading: 'Читаем подробности', @@ -2233,7 +2240,7 @@ export const pt = { updateRunning: 'Atualizando', updateStepChecking: 'Verifique {version}', updateStepRemoving: 'Pare e remova {version}', - updateStepInstalling: 'Instalar {version} de {registry}', + updateStepInstalling: 'Instalar {version} de {source}', updateStepRestoring: 'Instale {version} novamente', updateDone: 'Atualizado para {version}', updateDoneDetail: 'A nova versão está em execução.', @@ -2358,6 +2365,7 @@ export const pt = { reasonIncompatibleVersionUnnamed: 'Este plug-in é incompatível com a versão DSH em execução; executá-lo pode causar travamentos ou perda de dados.', reasonIncompatibleInstall: 'Instale uma versão de plug-in compatível com este DSH.', reasonIncompatibleInstalled: 'Desinstale-o e instale uma versão compatível com este DSH.', + reasonUnexpectedPackage: 'A fonte do plug-in instalou um pacote ou uma versão diferente do que foi solicitado; a instalação foi desfeita. Informe o problema ao editor do plug-in ou ao responsável pelo índice de plug-ins.', reasonOperationError: 'O Host relatou um erro.', detailTitle: 'Detalhes', detailLoading: 'Lendo detalhes', 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 2ca4f1cdb6..5554b1fe0c 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -38,7 +38,7 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' import type { SettingsDescribeFace, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client' import type { ConfigLedger } from './config-ledger.ts' -import type { PluginTabInstall } from './slot-contract.ts' +import type { PluginTabInstall, PluginTabUpdate } from './slot-contract.ts' import type { PluginTab } from './tab-ledger.ts' import { shortName } from './presentation.ts' import { BundleDetailController, type BundleDetailsState } from './detail-store.ts' @@ -123,8 +123,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 + /** How the Host updates the bundle: `registry` lists and installs newer registry versions; `git` installs a Git spec a tab names. */ + readonly update?: BundleInfo['update'] /** 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. */ @@ -361,13 +361,20 @@ export function installable(version: PluginVersion): boolean { return version.bundle && version.incompatible === undefined } +/** + * Where an update's target comes from: the registry that listed it, which the Host checks and installs it from, or + * the pinned Git spec a Discover entry names. + */ +export type UpdateSource = + | { readonly kind: 'registry'; readonly registry: Registry } + | { readonly kind: 'git'; readonly spec: string } + /** 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 source: UpdateSource readonly phase: 'confirm' | 'running' | 'done' /** The steps the Host announced, in order; the last one runs while the update does. */ readonly steps: readonly PluginUpdateStep[] @@ -431,7 +438,7 @@ export interface PluginManagerFace { detailImage: BundleDetailController['image'] /** Read the Host once the tab first renders. */ ensure: () => void - /** Read the Host again, then check every updatable package for newer versions. */ + /** Read the Host again, then check every registry package for newer versions. */ refresh: () => void openInstall: () => void /** @@ -476,12 +483,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. */ + /** Ask the Host for a registry 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 + /** + * Ask before replacing a Git copy with a newer Git version another tab names, such as a Discover entry, from the + * version the list holds; ignored unless the list holds the package as a Git copy, and while an update runs. + * @param target - the package, the target version, and its Git spec. + */ + openGitUpdate: (target: PluginTabUpdate) => void /** Start the confirmed update. */ runUpdate: () => void /** Leave the confirmation or the outcome; a running update keeps its dialog. */ @@ -575,7 +588,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): ...bundle.listedByDefault === undefined ? {} : { listedByDefault: bundle.listedByDefault }, optional: bundle.optional, removable: bundle.removable, - updatable: bundle.updatable, + ...bundle.update === undefined ? {} : { update: bundle.update }, enabled: bundle.enabled, rows, ...bundle.version === undefined ? {} : { version: bundle.version }, @@ -867,9 +880,13 @@ export class PluginManagerController { 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, - } }) + this.openUpdateDialog(packageName, check.list.installed, version, { kind: 'registry', registry: check.list.registry }) + }, + openGitUpdate: ({ packageName, version, spec }) => { + const { packages, update } = this.getSnapshot() + const pkg = packages.find(item => item.name === packageName) + if (update?.phase === 'running' || pkg?.update !== 'git' || pkg.version === undefined) return + this.openUpdateDialog(packageName, pkg.version, version, { kind: 'git', spec }) }, runUpdate: () => { void this.runUpdate() }, closeUpdate: () => { if (this.getSnapshot().update?.phase !== 'running') this.patch({ update: null }) }, @@ -900,6 +917,11 @@ export class PluginManagerController { return started } + /** Show an update's confirmation. */ + private openUpdateDialog(packageName: string, from: string, to: string, source: UpdateSource): void { + this.patch({ update: { packageName, from, to, source, phase: 'confirm', steps: [], result: null, outputOpen: false } }) + } + /** * Follow the Host's steps of this tab's update. * @param progress - a request id and step received from the Host. @@ -922,35 +944,37 @@ export class PluginManagerController { 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. */ + /** Check the registry 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) + for (const pkg of this.getSnapshot().packages.filter(item => item.update === 'registry')) await this.checkVersions(pkg.name) } - /** Hand the confirmed update to the Host, keep its outcome, then read the package and its versions again. */ + /** Hand the confirmed update to the Host, keep its outcome, then read the package and a registry package's 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 }) + const { source } = update + const answer = await this.ctx.remote.pluginManager.updateBundle(update.packageName, update.to, + source.kind === 'registry' ? { registry: source.registry, requestId } : { spec: source.spec, 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) + if (source.kind === 'registry') 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. + * Forget the checks that no longer describe a listed package: one that left the list or stopped being a registry + * package, 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) + return pkg?.update === 'registry' && (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 }) } @@ -1051,7 +1075,7 @@ export class PluginManagerController { } /** - * Keep manual refresh feedback until its coalesced reads and the version checks of the updatable packages settle, + * Keep manual refresh feedback until its coalesced reads and the version checks of the registry packages settle, * without clearing cached cards; a failed read checks nothing. */ private async refresh(): Promise { diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index e532eadc9e..8709103837 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -60,6 +60,7 @@ const CODE_KEYS = { 'stale-approval': 'reasonStaleApproval', 'incompatible-version': 'reasonIncompatibleVersionUnnamed', 'unsupported-platform': 'reasonUnsupportedPlatform', + 'unexpected-package': 'reasonUnexpectedPackage', 'operation-error': 'reasonOperationError', } satisfies Record 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 1450f2bb96..a432712b4c 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -9,7 +9,7 @@ * `import type` and registers through `ctx.slots`; it never imports this * package at runtime. * - * A contributed tab, such as Discover, installs through the page's own dialog. + * A contributed tab, such as Discover, installs and updates through the page's own dialogs. * * Entries accept `page` for the form with its own save control and `summary` * for an official card's one-liner or a row's missing-description fallback. @@ -91,6 +91,16 @@ export interface PluginTabInstall { readonly version: string } +/** A newer Git version of an installed bundle that a tab asks the Plugins page to update to. */ +export interface PluginTabUpdate { + /** npm package name of the installed bundle. */ + readonly packageName: string + /** The target version. */ + readonly version: string + /** The Git spec that installs `version`, pinned to a full commit of the repository directory the bundle came from. */ + readonly spec: string +} + /** Owner props of a page tab another plugin contributes, such as Discover. */ export interface PluginTabProps { /** @@ -99,6 +109,13 @@ export interface PluginTabProps { * @param target - the spec, optional registry, and display text. */ readonly install: (target: PluginTabInstall) => void + /** + * Open the page's update dialog at its confirmation for a bundle the Host lists with `update: 'git'`, from the + * version the page lists; nothing opens before the page has listed the bundle or while another update runs. The + * update removes the installed version, installs the target, and installs the removed version again when that fails. + * @param target - the package, the target version, and its Git spec. + */ + readonly update: (target: PluginTabUpdate) => void /** * Switch to the Installed tab and open a bundle's page, whose back button returns to the calling tab. * @param packageName - npm package name of the bundle. @@ -118,8 +135,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'plugins.add.actions': { kind: 'list'; scope: 'root'; owner: PluginAddActionsProps } /** * Tabs after the page's own Installed tab, such as Discover: `label` names the tab and `order` places it. The - * page renders the selected entry under its header and tabs; the entry draws its own content and installs - * through the owner's `install`. + * page renders the selected entry under its header and tabs; the entry draws its own content and installs and + * updates through the owner's `install` and `update`. */ 'plugins.tab': { kind: 'list'; scope: 'root'; owner: PluginTabProps } /** Optional guidance after the user enables a bundle from the list, keyed by npm package name. */ diff --git a/packages/client/ui-plugin-manager/src/client/versions.tsx b/packages/client/ui-plugin-manager/src/client/versions.tsx index dfb352a890..22231e4672 100644 --- a/packages/client/ui-plugin-manager/src/client/versions.tsx +++ b/packages/client/ui-plugin-manager/src/client/versions.tsx @@ -1,7 +1,8 @@ /** - * 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. + * A registry bundle's Versions section and the update dialog. The section summarizes the Host's version check in one + * row and unfolds into the newer versions; the dialog confirms one update of a registry or Git copy, 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' @@ -12,7 +13,7 @@ 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 { installable, updateCandidate, type PackageView, type UpdateSource, type UpdateState, type VersionCheck } from './manager-store.ts' import { managementText, registryText, restartText, type Translate } from './presentation.ts' import css from './PluginManagerPage.module.css' @@ -42,6 +43,12 @@ function registryName(registry: Registry, t: Translate): string { return registryText(registry, t, null).name } +/** Where an update installs from: the registry by name, or a pinned Git spec's repository as host and path. */ +function sourceName(source: UpdateSource, t: Translate): string { + if (source.kind === 'registry') return registryName(source.registry, t) + return source.spec.replace(/^git\+/i, '').replace(/#.*$/s, '').replace(/^[a-z]+:\/\//i, '').replace(/\.git$/i, '') +} + /** 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(', ') @@ -221,7 +228,7 @@ function VersionList({ list, checkedAt, t, busy, onUpdate }: { } /** - * The Versions section of an updatable bundle's page. It checks once when the page opens and folds the newer versions + * The Versions section of a registry 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 }: { @@ -235,9 +242,10 @@ export function VersionSection({ pkg, check, t, busy, onEnsure, onCheck, onUpdat 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 { name } = pkg + const listsVersions = pkg.update === 'registry' + useEffect(() => { if (listsVersions) onEnsure(name) }, [name, listsVersions, onEnsure]) + if (!listsVersions) return null const done = check?.phase === 'done' ? check : undefined const summary = summaryOf(check, t) const refused = done?.list.status === 'refused' ? done.list : undefined @@ -304,7 +312,7 @@ function stepLabel(step: PluginUpdateStep, update: UpdateState, t: Translate): s 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 'installing': return t('updateStepInstalling', { version: update.to, source: sourceName(update.source, t) }) case 'restoring': return t('updateStepRestoring', { version: update.from }) } } @@ -367,7 +375,14 @@ function outcomeOf(update: UpdateState, title: string, t: Translate): Omit
{t('updateSource')}
-
{registryName(update.registry, t)}
+
{sourceName(update.source, t)}

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

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 79c530ccf4..640f2428c9 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -43,7 +43,6 @@ function pkg(overrides: Partial = {}): PackageView { installed: true, optional: false, removable: true, - updatable: false, enabled: true, rows: [], ...overrides, @@ -143,6 +142,7 @@ function renderTab( checkVersions: vi.fn(), ensureVersions: vi.fn(), openUpdate: vi.fn(), + openGitUpdate: vi.fn(), runUpdate: vi.fn(), closeUpdate: vi.fn(), toggleUpdateOutput: vi.fn(), @@ -235,6 +235,7 @@ describe('PluginManagerPage', () => { return ( <> + ) @@ -254,6 +255,8 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('button', { name: en.refresh })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Discover install' })) expect(actions.installFrom).toHaveBeenCalledWith({ spec: 'dsh-a@1.0.0', registry: 'https://r.example/', title: 'Alpha', version: '1.0.0' }) + fireEvent.click(screen.getByRole('button', { name: 'Discover update' })) + expect(actions.openGitUpdate).toHaveBeenCalledWith({ packageName: 'dsh-a', version: '1.1.0', spec: 'git+https://git.example/o/a.git#bbbbbbb' }) fireEvent.click(screen.getByRole('button', { name: 'Discover open' })) expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'package', name: 'dsh-better-sidebar', returnTab: 'discover' } }) expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull() @@ -530,9 +533,9 @@ describe('PluginManagerPage', () => { 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, 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: [] }, + { 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: [] }, ] }) 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' }) }) @@ -1524,7 +1527,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, updatable: false, enabled: true, rows: [] } + const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', optional: false, removable: true, 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: [] } }) @@ -2692,7 +2695,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, updatable: false, enabled: false, rows: [], + optional: false, removable: false, enabled: false, rows: [], installTarget: { spec: `${name}@2.0.0`, version: '2.0.0' }, }) @@ -2834,7 +2837,7 @@ it('keeps bundle configuration mounted while its operation is pending', () => { 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 updatable = (): PackageView => pkg({ update: 'registry', 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, @@ -2947,8 +2950,11 @@ describe('registry versions and updates', () => { 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' })] }) + it.each([ + ['linked', pkg({ source: 'link:/dev/dsh-better-sidebar' })], + ['Git', pkg({ update: 'git', version: '0.16.0', source: 'git+https://git.example/o/plugins.git#aaaaaaa' })], + ])('shows no Versions section for a %s package', (_name, linked) => { + const { actions, navigation } = renderTab({ packages: [linked] }) open(navigation) expect(section()).toBeNull() expect(actions.ensureVersions).not.toHaveBeenCalled() @@ -2966,7 +2972,9 @@ describe('registry versions and updates', () => { 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 base: UpdateState = { + packageName: 'dsh-better-sidebar', from: '1.3.0', to: '1.4.0', source: { kind: 'registry', 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, }) @@ -2983,6 +2991,14 @@ describe('registry versions and updates', () => { expect(actions.closeUpdate).toHaveBeenCalledOnce() }) + it('names a Git update\'s repository as the place it installs from', () => { + const source = { kind: 'git', spec: 'git+https://git.example/o/plugins.git#bbbbbbb&path:/packages/sidebar' } as const + const { set } = renderTab({ packages: [updatable()], update: { ...base, source } }) + expect(within(dialog()).getByText('git.example/o/plugins')).toBeTruthy() + set({ update: { ...base, source, phase: 'running', steps: ['checking', 'removing', 'installing'] } }) + expect(within(dialog()).getByText('Install 1.4.0 from git.example/o/plugins')).toBeTruthy() + }) + 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() @@ -3029,6 +3045,16 @@ describe('registry versions and updates', () => { expect(actions.closeUpdate).toHaveBeenCalledTimes(2) }) + it.each([ + ['a typed refusal', { code: 'unexpected-package' as const }, `1.4.0 failed to install; 1.3.0 was installed again. ${en.reasonUnexpectedPackage}`], + ['pnpm\'s own failure', { code: 'operation-error' as const, diagnostic: 'ERR_PNPM_FETCH_404' }, '1.4.0 failed to install; 1.3.0 was installed again.'], + ])('says why a restored update failed for %s', (_name, error, detail) => { + renderTab({ packages: [updatable()], update: { + ...base, phase: 'done', result: { from: '1.3.0', to: '1.4.0', step: 'install', result: change('failed', { error }), restore: change('applied') }, + } }) + expect(within(dialog()).getByText(detail)).toBeTruthy() + }) + it('lists the steps the answer implies beside the announced ones', () => { renderTab({ packages: [updatable()], update: { ...base, phase: 'done', steps: ['checking', 'removing'], 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 dd8cb347e7..eb44f8eeb5 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 @@ -25,7 +25,6 @@ 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: [], } @@ -132,7 +131,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, updatable: false, enabled: false, + installed: true, official: false, availability: 'profile', optional: false, removable: true, 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 }, @@ -140,13 +139,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, updatable: false, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', enabled: true, installed: false, official: false, availability: 'installation', optional: false, removable: 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, updatable: false, enabled: true, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', installed: false, official: false, availability: 'installation', optional: false, removable: 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' }, @@ -158,7 +157,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, updatable: false, rows: [], overrides: [] } + const plain = { enabled: true, installed: true, official: false, availability: 'profile' as const, optional: false, removable: true, 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)) @@ -769,7 +768,7 @@ describe('PluginManagerController', () => { it('accepts a bundled package without optional version or description and reports a lost activation reply', async () => { const shipped: BundleInfo = { name: '@acme/shipped-exploration', installed: false, official: true, enabled: false, - availability: 'installation', optional: true, removable: false, updatable: false, listedByDefault: false, rows: [], overrides: [] } + availability: 'installation', optional: true, removable: false, listedByDefault: false, rows: [], overrides: [] } const { plugins, face, state, controller } = bench({ listBundles: vi.fn(() => Promise.resolve(ok([shipped]))), setBundleEnabled: vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))), @@ -1975,7 +1974,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, updatable: false, rows: [], overrides: [], + enabled: false, removable: false, rows: [], overrides: [], installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' }, } @@ -2210,7 +2209,7 @@ it('deselects a source bundle whose target is unavailable without starting insta }) describe('version checks and updates', () => { - const UPDATABLE: BundleInfo = { ...BUNDLE, updatable: true } + const UPDATABLE: BundleInfo = { ...BUNDLE, update: 'registry' } 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 }], @@ -2257,7 +2256,7 @@ describe('version checks and updates', () => { 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 }])) + listBundles.mockResolvedValue(ok([{ ...UPDATABLE, version: '0.17.0' }, { ...BUNDLE, name: 'dsh-a' }])) await controller.load() expect(state().versions).toEqual({}) }) @@ -2299,7 +2298,8 @@ describe('version checks and updates', () => { 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, + packageName: 'dsh-better-sidebar', from: '0.16.0', to: '0.17.0', source: { kind: 'registry', registry: MIRROR }, + phase: 'confirm', steps: [], result: null, outputOpen: false, }) face.closeUpdate() expect(state().update).toBeNull() @@ -2362,6 +2362,39 @@ describe('version checks and updates', () => { await late.promise expect(state().update?.phase).toBe('running') }) + + it('confirms a Git update another tab names once the list holds the package, runs it with the spec, and checks no versions', async () => { + const spec = 'git+https://git.example/o/plugins.git#bbbbbbb&path:/packages/sidebar' + const answer = deferred>>() + const updateBundle = vi.fn(() => answer.promise) + const listVersions = vi.fn(() => Promise.resolve(ok(LISTED))) + const listBundles = vi.fn(() => Promise.resolve(ok([ + { ...BUNDLE, update: 'git' as const, source: 'git+https://git.example/o/plugins.git#aaaaaaa&path:/packages/sidebar' }, + { ...BUNDLE, name: 'dsh-registry', update: 'registry' as const }, + ]))) + const { controller, face, state } = bench({ updateBundle, listVersions, listBundles }) + const target = { packageName: 'dsh-better-sidebar', version: '0.17.0', spec } + // The installed version comes from the list, which holds nothing before its first read and only Git copies update so. + face.openGitUpdate(target) + expect(state().update).toBeNull() + await controller.load() + face.openGitUpdate({ ...target, packageName: 'dsh-registry' }) + expect(state().update).toBeNull() + face.openGitUpdate(target) + expect(state().update).toEqual({ + packageName: 'dsh-better-sidebar', from: '0.16.0', to: '0.17.0', source: { kind: 'git', spec }, phase: 'confirm', steps: [], result: null, outputOpen: false, + }) + face.runUpdate() + const requestId = state().update?.requestId as PluginInstallRequestId + expect(updateBundle).toHaveBeenCalledWith('dsh-better-sidebar', '0.17.0', { spec, requestId }) + face.openGitUpdate({ ...target, version: '1.0.0' }) + expect(state().update).toMatchObject({ phase: 'running', to: '0.17.0' }) + answer.resolve(ok(UPDATED)) + await vi.waitFor(() => { expect(state().update).toMatchObject({ phase: 'done', result: UPDATED }) }) + await vi.waitFor(() => { expect(listBundles).toHaveBeenCalledTimes(2) }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(listVersions).not.toHaveBeenCalled() + }) }) diff --git a/packages/client/ui-plugin-market/README.i18n.yaml b/packages/client/ui-plugin-market/README.i18n.yaml index f7f071560f..64bebd3d61 100644 --- a/packages/client/ui-plugin-market/README.i18n.yaml +++ b/packages/client/ui-plugin-market/README.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-market/README.md /: - en: b77d6d729562a88f - zh: b1634cc1d027424c + en: 3685af81d8cbfba4 + zh: 2ec4eeb51778e2c8 /deepseek-ai-dsh-client-ui-plugin-market: en: 62972eb0dff8c145 zh: 682cc03b492cd730 /deepseek-ai-dsh-client-ui-plugin-market/summary: - en: 71328d505b984fe5 - zh: 9281e7dbaa01ef9a + en: ee0bd00afa1e0887 + zh: b54e1bdd523437dd /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: 4e582bcd8434b513 - zh: 39334bf8ae435e09 + en: d633d36f830d052c + zh: 07de610c0b1bb34c /deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation: - en: e1a480caaff8efea - zh: 0b725fa5bc929f22 + en: 66cef49ded72a290 + zh: c89a13fa5e5fb965 /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: 493d1326b1645f3f - zh: 878a53ed6cb7878d + en: 1a06b23ccce41074 + zh: 8c4f5d607b342ef9 /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 ccffb81311..b4d70f233b 100644 --- a/packages/client/ui-plugin-market/README.md +++ b/packages/client/ui-plugin-market/README.md @@ -1,5 +1,5 @@ --- -description: "Discover tab of the Web Plugins page: search configured plugin indexes, read an entry, and install it." +description: "Discover tab of the Web Plugins page: search configured plugin indexes, read an entry, and install or update it." kind: "package-reference" --- @@ -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 indexes offer; users manage index sources, switch between indexes, 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. +Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin indexes offer; users manage index sources, switch between indexes, search, filter by category and label, sort, open an entry's page, install an entry after confirming it, and update a bundle installed from Git to a newer Git version. 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,9 +28,9 @@ Open **Plugins** and select **Discover**. With no index source, the tab shows an The toolbar holds the index menu, which switches indexes and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the index declares labels; the **Sort** menu; and a refresh button. Controls wrap onto additional rows when space is limited, keeping search, filtering, sorting, and refresh available on mobile. Category pills, with **All** first, follow when the index 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, 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 index title. 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. +Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Update** for a bundle installed from a Git commit when the default version, else the newest stable version, is a newer commit of the repository directory it came from; **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 index title. 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. A row offers **Update** for each version the Host marks as an update of a bundle installed from a Git commit, and **Install** while the entry is not installed, each only for a version installation accepts; installation never goes over an installed or provided bundle. 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. +**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. **Update** opens the Plugins page's update confirmation without a dialog of its own; it names the installed and target versions and the repository, and the update removes the installed copy, installs the version's pinned Git spec, and installs the removed copy again when that fails. 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**. @@ -45,7 +45,7 @@ Reading an index shows card skeletons. A failed read shows a short sentence with 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 index 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. +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 })`, `update({ packageName, version, spec })`, and `openPackage(name)`; Discover passes the entry's localized title and the chosen version to `install`, and the version a Host listing marks `update` with its install spec to `update`. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another index 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. ----- @@ -62,7 +62,7 @@ None. -- **No upgrade action** — an available update is shown as a status only. +- **Git updates only** — Discover updates only a bundle installed from a Git commit. For a bundle installed from a registry, **Update available** opens its Installed page, whose Versions section updates it from the registry. For any other bundle with a newer index version, including a Git copy whose newer versions come from npm, another repository, or are incompatible, **Update available** opens an Installed page that offers no update; uninstall it and install it again. - **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 db4e749056..77c1d7d638 100644 --- a/packages/client/ui-plugin-market/README.zh.md +++ b/packages/client/ui-plugin-market/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web 插件页的「发现」标签页:搜索已配置的插件索引,查看条目并安装。" +description: "Web 插件页的「发现」标签页:搜索已配置的插件索引,查看条目并安装或更新。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件索引提供的组合包(bundle);用户可以管理索引源、在插件索引之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 +「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件索引提供的组合包(bundle);用户可以管理索引源、在插件索引之间切换、搜索、按分类和标签筛选、排序、打开条目页面、在确认后安装条目,以及把从 Git 安装的组合包更新到较新的 Git 版本。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 ## 目录 @@ -28,9 +28,9 @@ kind: "package-reference" 工具栏包含:插件索引菜单,用于切换插件索引,最后一项为**管理索引源…**;搜索框;插件索引声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。空间不足时,控件自动换行,确保手机端仍可使用搜索、筛选、排序和刷新。插件索引声明了分类时,其后显示分类按钮,**全部**排在第一位。 -每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件索引标题。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 +每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:从 Git 提交安装的组合包在默认版本(没有默认版本时为最新的稳定版本)是其来源仓库目录中较新的提交时显示**更新**;可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件索引标题。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本。Host 标为某个从 Git 提交安装的组合包之更新的每个版本,其行提供**更新**;条目未安装时,行提供**安装**;两者都只针对安装检查接受的版本。安装从不覆盖已安装或 DSH 随附的组合包。页面的返回链接回到列表,搜索、筛选和排序保持不变。 -**安装**选择默认版本并打开确认对话框,显示条目的图标、标题、该版本、作者、来源信息、适用时的豁免提示,以及信任提示。确认后,插件页的安装对话框以该条目的精确 spec 和注册表在检查界面打开;不显示 spec 表单,被拒绝时提供**重试**。条目指定的注册表只用于这一次安装。 +**安装**选择默认版本并打开确认对话框,显示条目的图标、标题、该版本、作者、来源信息、适用时的豁免提示,以及信任提示。确认后,插件页的安装对话框以该条目的精确 spec 和注册表在检查界面打开;不显示 spec 表单,被拒绝时提供**重试**。条目指定的注册表只用于这一次安装。**更新**不打开自己的对话框,直接打开插件页的更新确认框;该确认框显示已安装版本、目标版本和仓库;更新会移除已安装副本,安装该版本固定的 Git spec,安装失败时重新安装被移除的副本。 Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this-package) 负责匹配、过滤、排序和分页;标签页在停止输入 250 毫秒后发送搜索文本。搜索模糊匹配名称、作者、关键词,以及标题、描述、分类和标签的所有语言。所选标签必须全部匹配。有搜索文本时,**排序**提供**最佳匹配**,此外还提供**默认顺序**、**发布时间**以及每个已声明的排序键,排序键以其声明的方向开始;有方向的排序会额外显示一行,用于反转方向。输入文本会把**默认顺序**切换为**最佳匹配**,清空文本时切换回来。分类、标签和排序键按本地化标题列出。列表加载 50 个条目,每次点击**显示更多**再增加 50 个。 @@ -45,7 +45,7 @@ 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"` 延迟加载,失败时回退到默认插画。 +本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })`、`update({ packageName, version, spec })` 和 `openPackage(name)`;发现页向 `install` 传入条目的本地化标题和所选版本,向 `update` 传入 Host 列表标有 `update` 的版本及其安装 spec。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件市场时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。 ----- @@ -62,7 +62,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- -- **没有升级操作**——可用更新只显示为状态。 +- **只支持 Git 更新**——「发现」只更新从 Git 提交安装的组合包。对于从注册表安装的组合包,**有新版本**会打开其在「已安装」中的页面,由该页面的版本区块从注册表更新。对于插件索引中有较新版本的其他组合包,包括较新版本来自 npm、来自另一个仓库或不兼容的 Git 副本,**有新版本**会打开其在「已安装」中的页面,但该页面不提供更新;需要先卸载再安装。 - **只支持纯 Markdown**——详细介绍文档省略原始 HTML(包括 HTML 图片和排版),相对链接(包括页内锚点)不可点击。 - **远程图标**——URL 图标由浏览器请求,会向图片主机暴露用户的 IP 地址。Web UI 通过 HTTPS 提供时,浏览器会拦截或升级来自 `http:` 索引的 `http:` 图标,加载失败的图标显示默认插画。 diff --git a/packages/client/ui-plugin-market/package.json b/packages/client/ui-plugin-market/package.json index 0430e20ef4..aae48bfb81 100644 --- a/packages/client/ui-plugin-market/package.json +++ b/packages/client/ui-plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-market", "description": "Discover tab of the dsh web Plugins page: browse configured plugin market indexes and install their bundles", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -67,6 +67,9 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ] } diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx index cad12593bf..ff59f12906 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx @@ -1,4 +1,4 @@ -/** Discover: search one plugin market index, read an entry's page, and install a version. */ +/** Discover: search one plugin market index, read an entry's page, and install or update a version. */ import { useEffect, useMemo, useState, type ReactNode } from 'react' import { Button, IconChevronDownOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconSearchOutlineRegular, @@ -75,19 +75,46 @@ function installable(compatibility: MarketCompatibility): boolean { return compatibility.compatible || compatibility.exempted } +/** Whether a version carries a SemVer prerelease suffix. */ +function prerelease(version: string): boolean { + return version.split('+')[0]?.includes('-') === true +} + +/** The version Update offers for an entry: the presented one when it updates the installed copy, else the newest stable update. */ +function updateTarget(entry: MarketListingEntry): MarketListingVersion | undefined { + const updates = (version: MarketListingVersion): boolean => version.update && installable(version.compatibility) + const presented = presentedVersion(entry) + return updates(presented) ? presented : entry.versions.find(version => updates(version) && !prerelease(version.version)) +} + function formatDate(timestamp: number, language: string, style: 'medium' | 'long'): string { return new Intl.DateTimeFormat(language, { dateStyle: style }).format(new Date(timestamp * 1000)) } -/** The one action an entry offers in its current state; Install chooses its default version. */ -function EntryAction({ entry, title, t, onInstall, onOpenInstalled }: { +/** What an entry's card and page do for its actions: install or update a version, or open the installed bundle. */ +interface EntryCallbacks { + readonly onInstall: (version: MarketListingVersion) => void + readonly onUpdate: (version: MarketListingVersion) => void + readonly onOpenInstalled: () => void +} + +/** The one action an entry offers in its current state; Install chooses its default version, and Update its update target. */ +function EntryAction({ entry, title, t, onInstall, onUpdate, onOpenInstalled }: { readonly entry: MarketListingEntry readonly title: string readonly t: Translate - readonly onInstall: (version: MarketListingVersion) => void - readonly onOpenInstalled: () => void -}): ReactNode { +} & EntryCallbacks): ReactNode { const { status } = entry.installation + const target = updateTarget(entry) + if (target !== undefined) { + return ( + + ) + } + const version = presentedVersion(entry) if (status !== 'not-installed') { return ( ) } - const version = presentedVersion(entry) if (!version.compatibility.compatible && !version.compatibility.exempted) { return ( @@ -108,16 +134,14 @@ function EntryAction({ entry, title, t, onInstall, onOpenInstalled }: { return } -function EntryCard({ entry, t, face, language, labels, onOpen, onInstall, onOpenInstalled }: { +function EntryCard({ entry, t, face, language, labels, onOpen, onInstall, onUpdate, onOpenInstalled }: { readonly entry: MarketListingEntry readonly t: Translate readonly face: DiscoverActions readonly language: string readonly labels: readonly DisplayDeclaration[] readonly onOpen: () => void - readonly onInstall: (version: MarketListingVersion) => void - readonly onOpenInstalled: () => void -}): ReactNode { +} & EntryCallbacks): ReactNode { const title = entryTitle(entry, face) const version = presentedVersion(entry) const date = formatDate(version.publishTimestamp, language, 'medium') @@ -137,32 +161,32 @@ function EntryCard({ entry, t, face, language, labels, onOpen, onInstall, onOpen
- +
) } -/** One row of the version list: its state tags and an Install button when installation accepts it. */ -function VersionRow({ entry, version, title, t, language, onInstall }: { +/** One row of the version list: its state tags, and Update for a newer Git version of a Git copy or Install while nothing is installed. */ +function VersionRow({ entry, version, title, t, language, onInstall, onUpdate }: { readonly entry: MarketListingEntry readonly version: MarketListingVersion readonly title: string readonly t: Translate readonly language: string readonly onInstall: (version: MarketListingVersion) => void + readonly onUpdate: (version: MarketListingVersion) => void }): ReactNode { const current = entry.installation.installedVersion === version.version - // DSH ships a provided bundle; the market cannot replace it. - const offered = !current && entry.installation.status !== 'provided' && installable(version.compatibility) const { compatibility } = version + const accepted = installable(compatibility) return (
  • {version.version} {version.version === entry.defaultVersion ? {t('versionDefault')} : null} - {version.version.split('+')[0]?.includes('-') === true ? {t('versionPrerelease')} : null} + {prerelease(version.version) ? {t('versionPrerelease')} : null} {current ? {t('versionInstalled')} : null} {compatibility.compatible ? null : {t(compatibility.exempted ? 'versionExempted' : 'versionIncompatible')}}
    @@ -174,17 +198,24 @@ function VersionRow({ entry, version, title, t, language, onInstall }: { : t('incompatible', { runtime: compatibility.runtimeVersion, range: Object.values(compatibility.requirements).join(' ') })}
    - {!offered ? null : ( + {accepted && version.update ? ( + + ) : null} + {/* Installation never goes over an installed copy; only an update replaces it. */} + {accepted && entry.installation.status === 'not-installed' ? ( - )} + ) : null}
  • ) } -function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInstall, onOpenInstalled }: { +function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInstall, onUpdate, onOpenInstalled }: { readonly entry: MarketListingEntry readonly listing: MarketSearchResult readonly t: Translate @@ -193,9 +224,7 @@ function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInsta readonly hooks: Pick readonly language: string readonly onBack: () => void - readonly onInstall: (version: MarketListingVersion) => void - readonly onOpenInstalled: () => void -}): ReactNode { +} & EntryCallbacks): ReactNode { const title = entryTitle(entry, face) const version = presentedVersion(entry) const date = formatDate(version.publishTimestamp, language, 'long') @@ -219,7 +248,7 @@ function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInsta {title === entry.name ? null : {entry.name}} - + {entry.description === undefined ? null :

    {face.resolveText(entry.description)}

    } {compatibility.compatible ? null : ( @@ -241,7 +270,8 @@ function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInsta

    {t('versionsTitle')}

      {entry.versions.map(item => ( - + ))}
    @@ -394,7 +424,7 @@ function unfiltered(query: DiscoverQuery): boolean { return query.text.trim() === '' && query.category === null && query.labels.length === 0 } -function Browser({ state, listing, t, face, hooks, language, onInstall, openPackage }: { +function Browser({ state, listing, t, face, hooks, language, onInstall, onUpdate, openPackage }: { readonly state: DiscoverState readonly listing: ListingState readonly t: Translate @@ -402,6 +432,7 @@ function Browser({ state, listing, t, face, hooks, language, onInstall, openPack readonly hooks: Pick readonly language: string readonly onInstall: (choice: InstallChoice) => void + readonly onUpdate: (choice: InstallChoice) => void readonly openPackage: (name: string) => void }): ReactNode { const { query, value } = listing @@ -423,7 +454,9 @@ function Browser({ state, listing, t, face, hooks, language, onInstall, openPack if (openEntry !== undefined && value !== null) { return ( { setOpened(null) }} - onInstall={(version) => { onInstall({ entry: openEntry, version }) }} onOpenInstalled={() => { openPackage(openEntry.name) }} /> + onInstall={(version) => { onInstall({ entry: openEntry, version }) }} + onUpdate={(version) => { onUpdate({ entry: openEntry, version }) }} + onOpenInstalled={() => { openPackage(openEntry.name) }} /> ) } const selected = state.indexes.find(index => index.url === listing.url) ?? { url: listing.url } @@ -542,7 +575,7 @@ function Browser({ state, listing, t, face, hooks, language, onInstall, openPack {value.entries.map(entry => ( { setOpened(entry) }} onInstall={(version) => { onInstall({ entry, version }) }} - onOpenInstalled={() => { openPackage(entry.name) }} /> + onUpdate={(version) => { onUpdate({ entry, version }) }} onOpenInstalled={() => { openPackage(entry.name) }} /> ))} {value.nextOffset === null @@ -560,7 +593,7 @@ function Browser({ state, listing, t, face, hooks, language, onInstall, openPack /** Render the Discover tab of the Plugins page. */ export function DiscoverSection(props: DiscoverSectionProps): ReactNode { - const { t, install, openPackage } = props + const { t, install, update, openPackage } = props const face: DiscoverActions = props const state = props.useDiscover(snapshot => snapshot) const language = props.useLocale(snapshot => snapshot.active) @@ -591,7 +624,10 @@ export function DiscoverSection(props: DiscoverSectionProps): ReactNode { body = ( { setConfirming(choice) }} openPackage={openPackage} /> + onInstall={(choice) => { setConfirming(choice) }} + /* The Plugins page confirms an update in its own dialog, which names both versions. */ + onUpdate={({ entry, version }) => { update({ packageName: entry.name, version: version.version, spec: version.install.spec }) }} + openPackage={openPackage} /> ) } return ( diff --git a/packages/client/ui-plugin-market/src/client/locales.ts b/packages/client/ui-plugin-market/src/client/locales.ts index 52753bd63a..3bcadd89cc 100644 --- a/packages/client/ui-plugin-market/src/client/locales.ts +++ b/packages/client/ui-plugin-market/src/client/locales.ts @@ -50,6 +50,8 @@ export const zh = { install: '安装', installTitle: '安装 {name}', installVersionTitle: '安装 {name} {version}', + update: '更新', + updateTitle: '将 {name} 更新到 {version}', openInstalled: '在已安装中查看 {name}', statusInstalled: '已安装', statusUpdate: '有新版本', @@ -144,6 +146,8 @@ export const en = { install: 'Install', installTitle: 'Install {name}', installVersionTitle: 'Install {name} {version}', + update: 'Update', + updateTitle: 'Update {name} to {version}', openInstalled: 'View {name} in Installed', statusInstalled: 'Installed', statusUpdate: 'Update available', @@ -235,6 +239,8 @@ export const ja = { 'install': 'インストール', 'installTitle': '{name}をインストール', 'installVersionTitle': '{name} {version}をインストール', + 'update': 'アップデート', + 'updateTitle': '{name}を{version}へアップデート', 'openInstalled': 'インストール済みで{name}を表示', 'statusInstalled': 'インストール済み', 'statusUpdate': 'アップデートあり', @@ -326,6 +332,8 @@ export const es = { install: 'Instalar', installTitle: 'Instalar {name}', installVersionTitle: 'Instalar {name} {version}', + update: 'Actualizar', + updateTitle: 'Actualizar {name} a {version}', openInstalled: 'Ver {name} en Instalado', statusInstalled: 'Instalado', statusUpdate: 'Actualización disponible', @@ -417,6 +425,8 @@ export const fr = { install: 'Installer', installTitle: 'Installer {name}', installVersionTitle: 'Installer {name} {version}', + update: 'Mise à jour', + updateTitle: 'Mettre à jour {name} vers {version}', openInstalled: 'Afficher {name} dans Installé', statusInstalled: 'Installé', statusUpdate: 'Mise à jour disponible', @@ -508,6 +518,8 @@ export const de = { install: 'Installieren', installTitle: 'Installieren Sie {name}', installVersionTitle: 'Installieren Sie {name} {version}', + update: 'Aktualisieren', + updateTitle: '{name} auf {version} aktualisieren', openInstalled: '{name} unter „Installiert“ anzeigen', statusInstalled: 'Installiert', statusUpdate: 'Update verfügbar', @@ -599,6 +611,8 @@ export const ru = { install: 'Установить', installTitle: 'Установить {name}', installVersionTitle: 'Установить {name} {version}', + update: 'Обновить', + updateTitle: 'Обновить {name} до {version}', openInstalled: 'Посмотреть {name} в списке «Установлено»', statusInstalled: 'Установлено', statusUpdate: 'Доступно обновление', @@ -690,6 +704,8 @@ export const pt = { install: 'Instalar', installTitle: 'Instalar {name}', installVersionTitle: 'Instalar {name} {version}', + update: 'Atualizar', + updateTitle: 'Atualizar {name} para {version}', openInstalled: 'Ver {name} em Instalado', statusInstalled: 'Instalado', statusUpdate: 'Atualização disponível', 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 9597605788..d2b58fd9b8 100644 --- a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx +++ b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx @@ -24,7 +24,7 @@ const COMMIT = '0123456789abcdef0123456789abcdef01234567' function release(name: string, version: string, fields: Partial = {}): MarketListingVersion { return { version, publishTimestamp: 1790476800, source: { type: 'npm' }, engines: {}, - install: { spec: `${name}@${version}` }, compatibility: { compatible: true }, ...fields, + install: { spec: `${name}@${version}` }, compatibility: { compatible: true }, update: false, ...fields, } } @@ -89,9 +89,10 @@ function renderTab(state: Partial = {}, dictionary: Record undefined), } const install = vi.fn() + const update = vi.fn() const openPackage = vi.fn() const props = { - t: translator(dictionary), install, openPackage, ...actions, + t: translator(dictionary), install, update, openPackage, ...actions, resolveText: (text: string | Record) => typeof text === 'string' ? text : text[language] ?? text.en, useDiscover: bindSnapshotSelector(store), useLocale: bindSnapshotSelector(locale), @@ -103,7 +104,7 @@ function renderTab(state: Partial = {}, dictionary: Record = {}): void => { set({ listing: { ...store.getSnapshot().listing as ListingState, query, value: { ...LISTING, ...value }, answered: query } }) } - return { actions, install, openPackage, set, answer } + return { actions, install, update, openPackage, set, answer } } const names = (): string[] => Array.from(document.querySelectorAll('[data-market-entry]')).map(node => node.getAttribute('data-market-entry') ?? '') @@ -322,7 +323,7 @@ describe('DiscoverSection', () => { expect(detail.getByRole('link', { name: 'https://git.example/o/c' }).closest('dd')?.querySelector('code')).toBeNull() }) - it('lists every version on the entry page and installs the one chosen', () => { + it('lists every version on the entry page and installs the one chosen while none is installed', () => { const name = 'dsh-v' const versions = [ release(name, '3.0.0-rc.1'), @@ -334,7 +335,8 @@ describe('DiscoverSection', () => { name, versions, defaultVersion: '1.1.0', installation: { status: 'different-version', installedVersion: '1.0.0+build.1' }, metadata: { keywords: [], labels: [], sortKeys: {} }, } - const { install } = renderTab({ listing: { url: A, query: INITIAL_QUERY, status: 'ready', value: { ...LISTING, entries: [multi] }, answered: INITIAL_QUERY, failure: null } }) + const listing = (entry: MarketListingEntry): ListingState => ({ url: A, query: INITIAL_QUERY, status: 'ready', value: { ...LISTING, entries: [entry] }, answered: INITIAL_QUERY, failure: null }) + const { install, set } = renderTab({ listing: listing(multi) }) expect(card(name).getByText(/^1\.1\.0 · /)).toBeTruthy() fireEvent.click(card(name).getByRole('button', { name: `View ${name}` })) const list = within(screen.getByRole('list', { name: `Versions of ${name}` })) @@ -343,11 +345,13 @@ describe('DiscoverSection', () => { .toEqual(['3.0.0-rc.1', '2.0.0', '1.1.0', '1.0.0+build.1']) expect(row('3.0.0-rc.1').getByText(en.versionPrerelease)).toBeTruthy() expect(row('2.0.0').getByText(en.versionIncompatible)).toBeTruthy() - expect(row('2.0.0').queryByRole('button')).toBeNull() expect(row('1.1.0').getByText(en.versionDefault)).toBeTruthy() expect(row('1.0.0+build.1').getByText(en.versionInstalled)).toBeTruthy() expect(row('1.0.0+build.1').queryByText(en.versionPrerelease)).toBeNull() - expect(row('1.0.0+build.1').queryByRole('button')).toBeNull() + // Installation never goes over an installed copy. + expect(list.queryByRole('button')).toBeNull() + set({ listing: listing({ ...multi, installation: { status: 'not-installed' } }) }) + expect(row('2.0.0').queryByRole('button')).toBeNull() fireEvent.click(list.getByRole('button', { name: `Install ${name} 3.0.0-rc.1` })) const dialog = within(screen.getByRole('dialog')) expect(dialog.getByText('dsh-v@3.0.0-rc.1')).toBeTruthy() @@ -355,6 +359,44 @@ describe('DiscoverSection', () => { expect(install).toHaveBeenCalledWith({ spec: 'dsh-v@3.0.0-rc.1', title: 'dsh-v', version: '3.0.0-rc.1' }) }) + it('updates a Git copy to a newer Git version from its card and its version list through the Plugins page', () => { + const name = 'dsh-u' + const git = (version: string, update: boolean): MarketListingVersion => release(name, version, { + source: { type: 'git', url: 'https://git.example/o/u', commit: COMMIT }, install: { spec: `git+https://git.example/o/u.git#${version}` }, update, + }) + const upgradable: MarketListingEntry = { + name, title: 'Upgrade', versions: [git('1.2.0', true), git('1.1.0', true), git('1.0.0', false), git('0.9.0', false)], defaultVersion: '1.2.0', + installation: { status: 'update-available', installedVersion: '1.0.0' }, metadata: { keywords: [], labels: [], sortKeys: {} }, + } + const { install, update } = renderTab({ listing: { url: A, query: INITIAL_QUERY, status: 'ready', value: { ...LISTING, entries: [upgradable] }, answered: INITIAL_QUERY, failure: null } }) + fireEvent.click(card(name).getByRole('button', { name: 'Update Upgrade to 1.2.0' })) + expect(update).toHaveBeenLastCalledWith({ packageName: name, version: '1.2.0', spec: 'git+https://git.example/o/u.git#1.2.0' }) + // The Plugins page confirms the update, so Discover opens no dialog of its own. + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(card(name).getByRole('button', { name: 'View Upgrade' })) + const row = (version: string) => within(document.querySelector(`[data-market-version="${version}"]`) as HTMLElement) + fireEvent.click(row('1.1.0').getByRole('button', { name: 'Update Upgrade to 1.1.0' })) + expect(update).toHaveBeenLastCalledWith({ packageName: name, version: '1.1.0', spec: 'git+https://git.example/o/u.git#1.1.0' }) + expect(row('1.0.0').queryByRole('button')).toBeNull() + expect(row('0.9.0').queryByRole('button')).toBeNull() + expect(within(document.querySelector('[data-market-detail="dsh-u"]') as HTMLElement).getAllByRole('button', { name: /^Update Upgrade to 1\.2\.0$/ })).toHaveLength(2) + expect(install).not.toHaveBeenCalled() + }) + + it('offers the newest stable update on the card when the default version does not update the Git copy', () => { + const name = 'dsh-w' + const git = (version: string, update: boolean): MarketListingVersion => release(name, version, { + source: { type: 'git', url: 'https://git.example/o/w', commit: COMMIT }, install: { spec: `git+https://git.example/o/w.git#${version}` }, update, + }) + const moved: MarketListingEntry = { + name, versions: [git('2.0.0-rc.1', true), release(name, '1.3.0'), git('1.2.0', true), git('1.0.0', false)], defaultVersion: '1.3.0', + installation: { status: 'update-available', installedVersion: '1.0.0' }, metadata: { keywords: [], labels: [], sortKeys: {} }, + } + const { update } = renderTab({ listing: { url: A, query: INITIAL_QUERY, status: 'ready', value: { ...LISTING, entries: [moved] }, answered: INITIAL_QUERY, failure: null } }) + fireEvent.click(card(name).getByRole('button', { name: `Update ${name} to 1.2.0` })) + expect(update).toHaveBeenLastCalledWith({ packageName: name, version: '1.2.0', spec: 'git+https://git.example/o/w.git#1.2.0' }) + }) + it('marks exempted versions and offers no version of a bundle DSH provides', () => { const name = 'dsh-x' const exempted = release(name, '2.0.0', { compatibility: { ...INCOMPATIBLE, exempted: true } }) diff --git a/packages/client/ui-plugin-market/tests/presentation.client.spec.ts b/packages/client/ui-plugin-market/tests/presentation.client.spec.ts index c00c66e9a4..94ad6c7c84 100644 --- a/packages/client/ui-plugin-market/tests/presentation.client.spec.ts +++ b/packages/client/ui-plugin-market/tests/presentation.client.spec.ts @@ -6,7 +6,7 @@ import { declaredQuery, displayDeclarations, indexLabel, INITIAL_QUERY, presente const resolveText = (text: LocalizedText): string => typeof text === 'string' ? text : text.zh ?? text.en function release(version: string, fields: Partial = {}): MarketListingVersion { - return { version, publishTimestamp: 0, source: { type: 'npm' }, engines: {}, install: { spec: `x@${version}` }, compatibility: { compatible: true }, ...fields } + return { version, publishTimestamp: 0, source: { type: 'npm' }, engines: {}, install: { spec: `x@${version}` }, compatibility: { compatible: true }, update: false, ...fields } } function entry(name: string, fields: Partial = {}, metadata: Partial = {}): MarketListingEntry { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a73e6e2d85..57651951e1 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,11 +18,11 @@ en: ead6be55c7c9d7cf zh: 125ad36db7328551 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 9d998ea39a7415b2 - zh: 79b6eeec7a6262bb + en: ae85dcbb25303332 + zh: 471224ebad67df91 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: a5580871b06143b6 - zh: 152465da61068c5d + en: 2fc65b908307b52e + zh: e5c1181ecd2da6e5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: e2649937f077d4e0 zh: 6ad604cb5adfc16b @@ -30,8 +30,8 @@ en: 3fd7072b662eed0a zh: 88c6c299c0a6f8cd /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: a53935420b1a6c0c - zh: 5248f2088059e551 + en: 88448dce667337fe + zh: 5d9b6035191a0b08 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: a39e67d79e171221 zh: 593e260c073336f9 @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 433ff539bd3d16b3 - zh: fcb37c1fbe503620 + en: 96ab15bc5b281c46 + zh: b1e073e60e348b0e /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 46a29dfee6..aa4fa2d978 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | | `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. A disabled `MenuItemButton` with a `disabledReason` stays hoverable and focusable (`aria-disabled`) but cannot be activated; it shows the reason in a `Tooltip` and exposes it as the row's description. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Shift+Tab closes back to the anchor; Escape returns from an open submenu before closing the root. Selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. A disabled `MenuItemButton` with a `disabledReason` stays hoverable and focusable (`aria-disabled`) but cannot be activated; it shows the reason in a `Tooltip` and exposes it as the row's description. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. | @@ -72,7 +72,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. | | `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. | +| `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. `diff` and `patch` fences classify rows by their marker column, color added and removed rows like the Sidebar file diff, and tokenize each file's removed and added sides with the language its header names, so source diffs keep syntax colors; the marker column stays in the copied source. | | `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. | | `useClipboardCopy` | Copies the current text with one-second success feedback, prevents overlapping or success-window repeats, and cancels its reset timer on unmount; late write completion cannot update an unmounted control. | | `UserMessageBubble` | User-message background, radius, content inset, typography, and plain-text wrapping. Accepts native div props; callers own row alignment, width, content, and actions. | @@ -97,7 +97,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` or `Medium`. Line artwork uses stroke widths of 1 and 1.3 SVG user units respectively; the `size` prop controls rendered dimensions. Strokes scale with the viewport: a 1-unit stroke in a 16-unit view box renders at 0.875 CSS px at 14px and 0.75 CSS px at 12px, before further transforms. The renderer does not apply non-scaling strokes; filled contours scale as geometry and do not respond to stroke-width changes. Shared menus render icons at 14px, or 12px in compact lists, through `Menu.module.css`; these are rendered sizes, not fixed stroke widths. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A submenu-free portalled `Menu` caps its card height against the layout viewport, with its pinned footer outside the inner scrolling region. `HoverCard` adds 20px to the frame's published top clearance, or keeps 20px in native fullscreen, while preserving its own minimum margin in ordinary browsers; while an anchor is inside that top strip, the card is never pushed lower than the anchor's bottom edge plus its gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use CSS Anchor Positioning to follow their trigger and constrain their dimensions to the layout viewport. Every placement candidate reserves 12px layout-viewport margins and uses the frame overlay inset as its minimum top edge; pointer-positioned contextual menus keep 12px at the top. When the preferred placement cannot fit, CSS tries a horizontal adjustment or the opposite side before using a window-bounded fallback. Browser zoom and panning move the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Internal scrolling, dismissal, and focus restoration remain independent. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` or `Medium`. Line artwork uses stroke widths of 1 and 1.3 SVG user units respectively; the `size` prop controls rendered dimensions. Strokes scale with the viewport: a 1-unit stroke in a 16-unit view box renders at 0.875 CSS px at 14px and 0.75 CSS px at 12px, before further transforms. The renderer does not apply non-scaling strokes; filled contours scale as geometry and do not respond to stroke-width changes. Shared menus render icons at 14px, or 12px in compact lists, through `Menu.module.css`; these are rendered sizes, not fixed stroke widths. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A portalled `Menu` caps its card height against the layout viewport, with its pinned footer outside the inner scrolling region; child cards use their own body portal, so a scrolling parent never clips them. `HoverCard` adds 20px to the frame's published top clearance, or keeps 20px in native fullscreen, while preserving its own minimum margin in ordinary browsers; while an anchor is inside that top strip, the card is never pushed lower than the anchor's bottom edge plus its gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use CSS Anchor Positioning to follow their trigger and constrain their dimensions to the layout viewport. Every placement candidate reserves 12px layout-viewport margins and uses the frame overlay inset as its minimum top edge; pointer-positioned contextual menus keep 12px at the top. When the preferred placement cannot fit, CSS tries a horizontal adjustment or the opposite side before using a window-bounded fallback. Browser zoom and panning move the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Internal scrolling, dismissal, and focus restoration remain independent. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information. @@ -142,10 +142,14 @@ The atoms cannot read the application locale, so every piece of user-facing copy `Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry. -Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. +Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. `isExternalTrigger(node)` lets the caller identify external controls whose pointer presses must reach its toggle handler without outside dismissal. Without this prop, presses outside the anchor and menu panels dismiss, including presses on `returnFocus` targets. `returnFocus` alone only controls focus restoration. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. `Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Tall portal menus retain the trigger’s horizontal alignment while using the window’s vertical scrolling space; horizontal clamping applies only when the card cannot fit beside that edge. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color. +Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight on a menu row enters its child, never activates the external popup trigger; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and uses the root menu's visual-viewport limits and WebKit coordinate normalization with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. JavaScript hover requires `(any-hover: hover)` and a non-touch pointer event, so secondary mice and pens can open children even when the primary pointer is touch. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there. + +`useMenuSubmenu` owns child state, focus transitions, and pointer grace; `SubmenuPanel` renders the portal, and `MenuRow` supplies the shared component, data, and child row presentation. Root document listeners use the hook's containment, active-list, and return operations. Child cards use CSS Anchor Positioning against an unscrolled box inside the parent card that matches the card. A side card opens to the parent's right with a 4px gap and aligns its top edge with the active row; when it does not fit, CSS tries the left side. The hook measures the row's offset within the parent card and the child's natural height on opening, window resize, list scrolling, and observed size changes; the card moves up only as far as needed to stay within the layout viewport, keeping 12px margins and the frame top clearance. `submenuNeedsPage` compares the child's natural width with both sides; when neither fits, an activated child replaces the parent page at the parent's position and at least its width. Pinch zoom moves child cards with the page, as it does parent menus. + `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. `MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. @@ -231,9 +235,11 @@ These limits define how the atoms behave at the edges; they are current package - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Math copies as TeX only within one Markdown document** — a selection that reaches outside its `MarkdownText` root, such as from a user message into a reply, keeps the browser's native copy, which joins KaTeX's hidden MathML text with the visible glyphs. Inside one document, the plain text of non-math content comes from `innerText`, so code-block language labels and copy-button text are included, as in native copy. +- **Diff fences infer colors per file and per side** — `diff` and `patch` fences parse hunk line budgets so later file headers stay chrome, tokenize each file's removed and added lines separately with the language its `---`/`+++` header names, and leave the body plain when that path is unknown. A streaming diff re-tokenizes on every chunk; the marker column stays in copied source. - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. - **Bare URLs end before non-ASCII punctuation** — the [tokenizer patch](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch) and the [transform patch](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch) end a GFM autolink literal at non-ASCII punctuation, symbols, or whitespace, so a URL with an unencoded character such as `(` or `・` in its path links only up to it; write such a URL as `<…>` or `[…](…)`. A URL followed directly by CJK letters still links them, because paths such as `/wiki/中文` are valid. Both patches are keyed to exact versions: an upgrade of either package must re-apply or retire its patch, otherwise the boundary rule silently disappears. - **The Go struct-field rule is patched locally** — when the Go grammar loads, its repeated one-line struct-field rule is replaced with the rule [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) merged, because the vendored rule backtracks exponentially on lines such as a brace-bearing comment after `struct {`. If a dependency upgrade removes that rule, loading the grammar throws and Go code renders as plain text; delete the patch once `@shikijs/langs` ships the upstream rule. +- **The Perl match rule is patched locally** — when the Perl grammar loads, its bare `/…/` match rule also begins after `,`, `!`, `=`, `=>` and after `split`, `grep`, `and`, `or`, `not`, `return`, `while` and `until`, because the vendored rule treats a pattern such as `split /^---\s*$/m, $text` as code and colors the rest of the source as a string. These words and `if`/`unless` count only when no identifier character, sigil, `>`, `:` or `-` precedes them, so `$split / 2` and `$obj->return / 2` are divisions. A `/` elsewhere, such as after `?`, `[` or `print`, still reads as division. The upstream [textmate/perl.tmbundle](https://github.com/textmate/perl.tmbundle) grammar has the same rule. If a dependency upgrade changes that rule, loading the grammar throws and Perl code renders as plain text. - **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists. - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **Product icons need the ui-theme sprite** — each icon only references its symbol, so without ui-theme, or inside another document such as an iframe, the icon renders nothing. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index f3d36cb06d..1eeebf6dac 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -49,7 +49,7 @@ kind: "package-library" | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | | `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | -| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 带 `disabledReason` 的禁用 `MenuItemButton` 仍可悬停和聚焦(`aria-disabled`),但不能激活;它在 `Tooltip` 中显示原因,并把原因作为该行的描述。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Shift+Tab 关闭并把焦点还给锚点;Escape 先退出已打开的子菜单,再关闭根菜单。选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 带 `disabledReason` 的禁用 `MenuItemButton` 仍可悬停和聚焦(`aria-disabled`),但不能激活;它在 `Tooltip` 中显示原因,并把原因作为该行的描述。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 | @@ -72,7 +72,7 @@ kind: "package-library" | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 | | `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | -| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | +| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。`diff` 与 `patch` 围栏按标记列划分行,按侧边栏文件 diff 的样式给新增、删除行上色,并按文件头写明的语言分别对该文件的删除侧与新增侧做语法高亮,因此源码 diff 保留语法颜色;标记列仍保留在复制的源码里。 | | `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 | | `useClipboardCopy` | 复制当前文本并显示一秒成功反馈,阻止写入期间及成功反馈期间的重复操作,卸载时清除重置计时器;迟到的写入结果不会更新已卸载控件。 | | `UserMessageBubble` | 用户消息的背景、圆角、内容内边距、字体与纯文本换行规则。接收原生 div props;行对齐、宽度、内容和操作由调用方拥有。 | @@ -97,7 +97,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 或 `Medium` 结尾。线条图形分别使用 1 和 1.3 SVG 用户单位的描边宽度;`size` prop 控制渲染尺寸。描边随视口缩放:16 单位 viewBox 中宽度为 1 的描边,在 14px 下为 0.875 CSS px,在 12px 下为 0.75 CSS px,尚未计入额外变换。渲染器不使用非缩放描边;填充轮廓随几何缩放,不受 stroke-width 变化影响。共享菜单通过 `Menu.module.css` 将图标渲染为 14px,紧凑列表为 12px;这些是渲染尺寸,不是固定描边宽度。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。无子菜单的 portal 模式 `Menu` 按布局视口限制卡片高度,固定页脚位于内部滚动区域之外。`HoverCard` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,卡片不会被推到低于锚点底边加自身间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 通过 CSS Anchor Positioning 跟随触发元素,并按布局视口限制尺寸。每个候选位置都保留 12px 布局视口边距,并将框架浮层起点作为顶部下限;按指针定位的上下文菜单顶部保留 12px。首选位置放不下时,CSS 尝试横向调整或相反方向,最后使用窗口内的回退位置。浏览器缩放和平移时,菜单随页面移动,可以超出视觉视口,包括键盘打开时。内部滚动、菜单关闭和焦点恢复保持独立。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 或 `Medium` 结尾。线条图形分别使用 1 和 1.3 SVG 用户单位的描边宽度;`size` prop 控制渲染尺寸。描边随视口缩放:16 单位 viewBox 中宽度为 1 的描边,在 14px 下为 0.875 CSS px,在 12px 下为 0.75 CSS px,尚未计入额外变换。渲染器不使用非缩放描边;填充轮廓随几何缩放,不受 stroke-width 变化影响。共享菜单通过 `Menu.module.css` 将图标渲染为 14px,紧凑列表为 12px;这些是渲染尺寸,不是固定描边宽度。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。portal 模式的 `Menu` 按布局视口限制卡片高度,固定页脚位于内部滚动区域之外;子卡片使用各自的 body portal,父级滚动不会裁剪它们。`HoverCard` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,卡片不会被推到低于锚点底边加自身间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 通过 CSS Anchor Positioning 跟随触发元素,并按布局视口限制尺寸。每个候选位置都保留 12px 布局视口边距,并将框架浮层起点作为顶部下限;按指针定位的上下文菜单顶部保留 12px。首选位置放不下时,CSS 尝试横向调整或相反方向,最后使用窗口内的回退位置。浏览器缩放和平移时,菜单随页面移动,可以超出视觉视口,包括键盘打开时。内部滚动、菜单关闭和焦点恢复保持独立。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。 @@ -142,10 +142,14 @@ kind: "package-library" `Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。 -锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 +锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。调用方通过 `isExternalTrigger(node)` 指定外部控件,使其指针按下事件不触发外部关闭,而由自己的切换处理器处理。省略该 prop 时,锚点与菜单面板之外的按下事件都会关闭菜单,包括 `returnFocus` 目标上的按下事件。单独设置 `returnFocus` 只控制焦点恢复。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 `Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。较高的 portal 菜单使用窗口内的纵向滚动空间时,仍保持与触发元素的横向对齐;只有卡片在该侧放不下时才调整横向位置。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。 +子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。菜单行上的 ArrowRight 进入其子级,不激活外部弹窗入口;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并采用根菜单相同的 visual viewport 限制与 WebKit 坐标归一化规则,在内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。JavaScript 悬停要求 `(any-hover: hover)` 与非触摸指针事件,所以主指针为触摸时,外接鼠标或笔仍可展开子级。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。 + +`useMenuSubmenu` 持有子级状态、焦点转换与指针宽限;`SubmenuPanel` 渲染 portal,`MenuRow` 统一组件行、数据行与子行的展示。根菜单文档监听通过 hook 的包含判定、当前列表与返回操作交互。子卡片以父卡片内一个不随列表滚动、与卡片同尺寸的锚点框为锚点,使用 CSS Anchor Positioning 定位。侧开卡片默认在父卡片右侧打开,间距 4px,顶边与当前父行对齐;放不下时 CSS 尝试左侧。hook 在打开、窗口尺寸变化、列表滚动和观察到尺寸变化时测量该行在父卡片内的偏移与子卡片的自然高度;卡片只在需要时上移,以留在布局视口内,并保留 12px 边距和框架顶部避让。`submenuNeedsPage` 比较子卡片的自然宽度与两侧空间;两侧都放不下时,激活的子菜单以单面板形式替换父页面,位于父卡片的位置,宽度至少与父卡片相同。双指缩放时子卡片和父菜单一样随页面移动。 + `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 `MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 @@ -231,9 +235,11 @@ kind: "package-library" - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **公式仅在单个 Markdown 文档内复制为 TeX**:超出其 `MarkdownText` 根节点的选区(例如从用户消息延伸到回复)沿用浏览器原生复制,会将 KaTeX 隐藏的 MathML 文本与可见字形拼接在一起。在单个文档内,非公式内容的纯文本取自 `innerText`,因此与原生复制一样,会包含代码块语言标签和复制按钮文本。 +- **Diff 围栏按文件、按侧推断颜色**:`diff` 与 `patch` 围栏解析 hunk 行数预算,使后续文件头仍按文件头处理;每个文件的删除行与新增行分别用其 `---`/`+++` 头写明的语言 tokenize,路径不可识别时正文保持纯文本。流式 diff 每来一个 chunk 都会重新 tokenize;标记列仍保留在复制的源码中。 - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 - **裸 URL 在非 ASCII 标点前结束**:[tokenizer 补丁](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch)和[转换补丁](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch)让 GFM 自动链接在非 ASCII 标点、符号或空白处结束,因此路径中含未编码 `(`、`・` 等字符的 URL 只链接到该字符之前;这类 URL 请写成 `<…>` 或 `[…](…)`。URL 后直接跟中日韩文字时仍会并入链接,因为 `/wiki/中文` 这类路径是合法的。两个补丁都绑定精确版本:升级任一依赖时必须重新应用或撤销对应补丁,否则该边界规则会悄然失效。 - **Go 结构体字段规则在本地打了补丁**:Go 语法加载时,重复匹配单行结构体字段的规则会替换为 [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) 合入的规则,因为内置规则在 `struct {` 之后含花括号注释等行上会指数级回溯。若依赖升级后该规则消失,加载语法会抛错,Go 代码以纯文本显示;`@shikijs/langs` 发布上游规则后删除此补丁。 +- **Perl 匹配规则在本地打了补丁**:Perl 语法加载时,其裸 `/…/` 匹配规则也会在 `,`、`!`、`=`、`=>` 之后以及 `split`、`grep`、`and`、`or`、`not`、`return`、`while` 和 `until` 之后开始,因为内置规则会把 `split /^---\s*$/m, $text` 这类模式当作代码,并将其余源码着色为字符串。这些词以及 `if`/`unless` 仅在前面没有标识符字符、前缀符号(sigil)、`>`、`:` 或 `-` 时才生效,因此 `$split / 2` 和 `$obj->return / 2` 是除法。其他位置的 `/`(例如在 `?`、`[` 或 `print` 之后)仍按除法读取。上游 [textmate/perl.tmbundle](https://github.com/textmate/perl.tmbundle) 语法有同样的规则。若依赖升级改变了该规则,加载语法会抛错,Perl 代码以纯文本显示。 - **鱼形标志是重新绘制的近似版本**:它来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **产品图标依赖 ui-theme 的 sprite**:每个图标只引用自己的 symbol,因此没有 ui-theme 时,或在 iframe 等另一个文档中,图标不渲染任何内容。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index b52813da70..02bb00cae3 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -55,7 +55,10 @@ "files": [ "lib/index.js", "lib/**/*.css", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 5679f43bd3..5d45e1cb85 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -7,14 +7,13 @@ /* The preview remains hit-testable so pointer entry cancels the grace close. */ .card { - --dsw-hovercard-bg: #2C2C2E; position: fixed; z-index: 100; box-sizing: border-box; width: 244px; padding: 12px 16px; border-radius: var(--dsw-radius-lg); - background: var(--dsw-hovercard-bg); + background: var(--dsw-alias-hovercard-bg); box-shadow: var(--dsw-shadow-lv3); } @@ -34,7 +33,7 @@ } .copied { - color: #FFFFFF; + color: var(--dsw-alias-hovercard-label-primary); font-size: 14px; line-height: 20px; text-align: center; diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 805d2b4d65..91c6f78fe8 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -1,11 +1,11 @@ .root { - --json-tree-property: #881391; - --json-tree-string: #c41a16; - --json-tree-number: #1c00cf; - --json-tree-keyword: #1c00cf; - --json-tree-punctuation: #202124; - --json-tree-icon: #5f6368; - --json-tree-hover: rgb(60 64 67 / 4%); + --json-tree-property: var(--dsw-alias-json-tree-property); + --json-tree-string: var(--dsw-alias-json-tree-string); + --json-tree-number: var(--dsw-alias-json-tree-number); + --json-tree-keyword: var(--dsw-alias-json-tree-keyword); + --json-tree-punctuation: var(--dsw-alias-json-tree-punctuation); + --json-tree-icon: var(--dsw-alias-json-tree-icon); + --json-tree-hover: var(--dsw-alias-json-tree-hover); min-width: 0; overflow: auto; @@ -17,16 +17,6 @@ overscroll-behavior-y: auto; } -:global(body[data-ds-dark-theme]) .root { - --json-tree-property: #5db0d7; - --json-tree-string: #f28b82; - --json-tree-number: #99c8ff; - --json-tree-keyword: #99c8ff; - --json-tree-punctuation: #e8eaed; - --json-tree-icon: #9aa0a6; - --json-tree-hover: rgb(232 234 237 / 5%); -} - .container { box-sizing: border-box; width: 100%; diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 31fc8a1939..34625df712 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -136,7 +136,8 @@ right: 0; } -/* Tall lists scroll within the layout viewport, independently of pinch zoom. */ +/* Tall lists scroll within the layout viewport, independently of pinch zoom. + * Child cards use their own body portal, so a scrolling parent never clips them. */ .scrollable { max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); @@ -362,24 +363,76 @@ display: block; } -/* The submenu aligns with the parent's bottom edge. Its hit bridge covers - * the menu inset and the 6px gap so pointer movement keeps it open. */ -.submenu { +/* The parent card's anchor for an open child card: an unscrolled box matching the + * card. WebKit does not re-resolve anchors on the anchor-positioned card itself + * after it settles, and resolves rows inside the scrolling list without their + * scroll offset; a descendant box avoids both. */ +.submenuAnchor { position: absolute; - top: auto; - bottom: -4px; - left: calc(100% + 10px); - z-index: 101; - min-width: 163px; + inset: 0; + pointer-events: none; } -.submenu::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -10px; - width: 10px; +/* Child cards stay mounted while switching between side and single-panel layouts. + * Both layouts use the parent card's anchor box as the default anchor, which + * browsers track through movement and zoom. JavaScript selects the layout and + * supplies the active row's offset within the parent card and the child card's + * natural height. */ +.submenu { + --dsh-menu-window-top: max(12px, var(--dsh-frame-overlay-top, 12px)); + position: fixed; + position-anchor: var(--dsh-submenu-parent); + position-visibility: always; + margin: 0; + z-index: 1101; + width: max-content; + min-width: min(163px, calc(100vw - 24px)); + max-width: min(360px, calc(100vw - 24px)); + max-height: calc(100dvh - 12px - var(--dsh-menu-window-top)); +} +.submenu[data-contextual] { --dsh-menu-window-top: 12px; } + +/* Side card: beside the parent card with a 4px gap, top edge on the active row, + * moved up when its height does not fit below the row. The only placement + * fallback is the left side. */ +.submenu[data-submenu-layout='side'] { + top: max(var(--dsh-menu-window-top), min( + calc(anchor(top) + var(--dsh-submenu-row-offset, 0px)), + calc(100dvh - 12px - var(--dsh-submenu-height, 0px)) + )); + left: calc(anchor(right) + 4px); + right: 12px; + justify-self: start; + height: max-content; + position-try-fallbacks: --dsh-submenu-left; +} +@position-try --dsh-submenu-left { + left: 12px; + right: calc(anchor(left) + 4px); + justify-self: end; +} + +/* Single-panel page: the child takes the parent card's place and at least its width. */ +.submenu[data-submenu-layout='page'] { + top: max(var(--dsh-menu-window-top), anchor(top)); + bottom: 12px; + left: max(12px, anchor(left)); + right: 12px; + align-self: start; + justify-self: start; + height: fit-content; + min-width: min(anchor-size(width), calc(100vw - 24px)); + max-width: calc(100vw - 24px); +} + +/* Retain the parent's geometry and rows for placement and focus restoration. */ +.parentPage { + visibility: hidden; + pointer-events: none; +} + +.submenu > .item { + flex: none; } .list[data-menu-phase='dismiss'], diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index d433e02be2..47b31bb407 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,15 +1,15 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode, RefObject, SyntheticEvent } from 'react' +import type { ReactNode, RefObject } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCheckOutlineRegular } from './icons/index.tsx' +import { useMenuSubmenu, menuHoverAllowed } from './useMenuSubmenu.ts' +import { MenuRow, menuIconColumns, renderMenuDecoration } from './MenuRow.tsx' +import { SubmenuPanel } from './SubmenuPanel.tsx' import { usePointerGrace } from './pointer-grace.ts' import { isBehindModal } from './useModalLayer.ts' import { observeComposition } from './keyboard-composition.ts' import { focusWithoutRing } from './focus.ts' -import { ShortcutKeys } from './ShortcutKeys.tsx' import { MenuSurface } from './MenuSurface.tsx' -import { Tooltip } from './Tooltip.tsx' import css from './Menu.module.css' /** Selectable row (optionally with a nested submenu). */ @@ -23,7 +23,7 @@ export interface MenuItem { icon?: ReactNode /** Destructive row: error-colored text/icon and danger hover fill. */ danger?: boolean - /** Nested card opened to the right on hover/focus. */ + /** Child choices opened by hover, focus, click, or ArrowRight; placement follows available space. */ submenu?: readonly MenuEntry[] } @@ -91,64 +91,8 @@ export interface MenuItemButtonProps { * @param props.onSelect - row activation callback. * @returns one menu-item row. */ -export function MenuItemButton({ - children, shortcut, icon, current, disabled = false, disabledReason, danger = false, separatorBefore = false, onSelect, -}: MenuItemButtonProps) { - const reasonId = useId() - // Native disabled buttons do not deliver the hover/focus events Tooltip needs, - // so an explained row is only marked unavailable. The Tooltip wraps every row, - // so a reason that comes or goes never remounts the focused row. - const explained = disabled && disabledReason !== undefined - return ( -
    - {separatorBefore &&
    } - - - - {explained && } -
    - ) -} - -function isSeparator(entry: MenuEntry): entry is MenuSeparator { - return 'type' in entry && entry.type === 'separator' -} - -function isLabel(entry: MenuEntry): entry is MenuLabel { - return 'type' in entry && entry.type === 'label' -} - -/** Mark icon columns within each separator-delimited data group. */ -function iconColumns(entries: readonly MenuEntry[], contextual: boolean): ReadonlySet { - const aligned = new Set() - if (!contextual) return aligned - let group: MenuEntry[] = [] - const finish = (): void => { - if (group.some(entry => !isLabel(entry) && !isSeparator(entry) && entry.icon !== undefined)) { - for (const entry of group) aligned.add(entry) - } - group = [] - } - for (const entry of entries) { - if (isSeparator(entry)) finish() - else group.push(entry) - } - finish() - return aligned +export function MenuItemButton({ onSelect, ...props }: MenuItemButtonProps) { + return } /** @@ -157,14 +101,21 @@ function iconColumns(entries: readonly MenuEntry[], contextual: boolean): Readon * list instead — and Escape or Shift+Tab close it and return focus to the * anchor's first button, and selecting a row does the same — the rows unmount * with the list. Only a keyboard on the trigger or inside the list is - * intercepted; Tab presses elsewhere on the page stay the browser's. + * intercepted; Tab presses elsewhere on the page stay the browser's. Escape + * first returns from a visible child, while ArrowRight enters and ArrowLeft returns. + * When neither side fits, click or ArrowRight opens a single-panel child page; + * focus alone retains the parent page. * @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment. * @param props.exiting - retain an inert popup during dismissal or replacement fade. * @param props.returnFocus - original control captured before asynchronous menu preparation. + * @param props.isExternalTrigger - recognizes external controls whose pointer presses should not dismiss; + * omitted, every press outside the anchor and menu panels dismisses. * @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable data rows and optional separators (default none; with no `children` either, the list is empty). + * @param props.submenuBackLabel - localized single-panel return label; omitted uses the caller's + * parent-row label beside a back arrow, without selecting that row. * @param props.selectedId - row shown as selected. * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - data-row activation callback (not called for disabled rows or submenu parents that only open children). @@ -202,10 +153,12 @@ function iconColumns(entries: readonly MenuEntry[], contextual: boolean): Readon * document.body outside the owner's DOM subtree. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', anchorRef, anchorPoint, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: { +export function Menu({ open, anchor, items = [], children, submenuBackLabel, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', anchorRef, anchorPoint, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus, isExternalTrigger }: { open: boolean /** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */ contextual?: boolean + /** Recognizes external triggers exempt from outside dismissal; omitted exempts no external nodes. */ + isExternalTrigger?: (node: Node) => boolean /** Resolve the original control for focus restoration and inert/hidden dismissal. */ returnFocus?: (() => HTMLElement | null) | undefined /** Retain a dismissed context popup during its short fade. */ @@ -213,6 +166,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI autoFocus?: boolean anchor: ReactNode items?: readonly MenuEntry[] + /** Localized return action; omitted uses the caller's parent label with a back arrow. */ + submenuBackLabel?: string | undefined children?: ReactNode footer?: readonly MenuEntry[] selectedId?: string | undefined @@ -296,14 +251,17 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI queueMicrotask(() => { if (openRef.current && exitingRef.current === undefined) return const active = document.activeElement - if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation) + if (active === null || active === document.body || listRef.current?.contains(active) === true + || submenu.contains(active)) refocusAnchor(navigation) }) } const exitingRef = useRef(exiting) exitingRef.current = exiting const openRef = useRef(open) openRef.current = open - const [openSubmenuId, setOpenSubmenuId] = useState(null) + const submenu = useMenuSubmenu({ open: open && exiting === undefined, items, footer, listRef, refocusAnchor }) + const submenuEvents = useRef>(submenu) + submenuEvents.current = submenu const close = useRef(onClose) close.current = onClose const anchorName = `--dsh-menu-trigger-${menuId.replaceAll(':', '')}` @@ -359,15 +317,14 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI useEffect(() => { if (!open || !positioned || exiting !== undefined || !autoFocus - || listRef.current?.contains(document.activeElement)) return + || listRef.current?.contains(document.activeElement) || submenu.contains(document.activeElement)) return const first = listRef.current?.querySelector('button:not(:disabled)') walkIndex.current = first === undefined || first === null ? null : 0 if (first != null) focusWithoutRing(first) - }, [open, autoFocus, items.length, footer?.length, exiting, positioned]) + }, [open, autoFocus, items.length, footer?.length, positioned, exiting]) useEffect(() => { if (!open || exiting !== undefined) { - setOpenSubmenuId(null) walkIndex.current = null return } @@ -376,7 +333,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (!(e.target instanceof Node) || (contextual && e.button === 2)) return // The portaled list is outside the anchor subtree; check both. if (rootRef.current?.contains(e.target) === true || anchorRef?.current?.contains(e.target) === true) return - if (listRef.current?.contains(e.target) === true) return + if (isExternalTrigger?.(e.target) === true) return + if (listRef.current?.contains(e.target) === true || submenuEvents.current.contains(e.target)) return onClose() } const onKeyDown = (e: KeyboardEvent) => { @@ -385,24 +343,35 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement - const insideList = listRef.current?.contains(focused) === true + const insideList = listRef.current?.contains(focused) === true || submenuEvents.current.contains(focused) const anchored = focused === returnFocus?.() || anchorRef?.current?.contains(focused) === true || rootRef.current?.contains(focused) === true || insideList if (e.key === 'Escape' && !e.shiftKey) { e.preventDefault() if (e.repeat) return + if (submenuEvents.current.returnToParent()) return // Closing hands the keyboard back when the menu had it — and, as this // primitive always did for autoFocus menus, when it held the keyboard // and lost it again (a row that unmounted under it). onClose() if (anchored || autoFocus) refocusAnchor() } + if (e.key === 'ArrowLeft' && anchored && submenuEvents.current.returnToParent()) { + e.preventDefault() + return + } + if (e.key === 'ArrowRight' && insideList && focused instanceof HTMLButtonElement + && focused.getAttribute('aria-haspopup') === 'menu') { + e.preventDefault() + focused.click() + return + } // Tab settles like Enter and Shift+Tab leaves like Escape, so a menu's // keys mean what they mean in the composer. Only a keyboard already on // the trigger or inside the list is intercepted: Tab elsewhere on the // page keeps the browser's traversal even while a menu is open. if (e.key === 'Tab') { - const list = listRef.current + const list = submenuEvents.current.activeList(focused) if (list === null || !anchored) return if (e.shiftKey) { e.preventDefault() @@ -444,7 +413,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // otherwise re-enter at the near end on every press and the walk would // alternate between two rows. if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return - const list = listRef.current + const list = submenuEvents.current.activeList(focused) if (list === null || !anchored) return const buttons = Array.from(list.querySelectorAll('button:not(:disabled)')) if (buttons.length === 0) return @@ -478,7 +447,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } - }, [open, onClose, autoFocus, contextual, exiting, anchorRef, returnFocus]) + }, [open, onClose, autoFocus, contextual, exiting, anchorRef, returnFocus, isExternalTrigger]) useLayoutEffect(() => { if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor() @@ -492,99 +461,41 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (!open) cancelClose() }, [open, cancelClose]) - // The submenu card is absolutely positioned outside the list box; the - // scroll clip would crop it, so only submenu-free menus get the height cap. - const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) - - const itemColumns = iconColumns(items, contextual) - const footerColumns = iconColumns(footer ?? [], contextual) + const itemColumns = menuIconColumns(items, contextual) + const footerColumns = menuIconColumns(footer ?? [], contextual) const renderEntry = (entry: MenuEntry, columns: ReadonlySet) => { - if (isSeparator(entry)) { - return
    - } - if (isLabel(entry)) { - return
    {entry.text}
    - } + if ('type' in entry) return renderMenuDecoration(entry) const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 - const subOpen = hasSub && openSubmenuId === entry.id - const subColumns = iconColumns(entry.submenu ?? [], contextual) - const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true - return ( -
    { setOpenSubmenuId(entry.id) } : undefined} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( - - {entry.submenu.map(sub => isSeparator(sub) - ?
    - : isLabel(sub) ?
    {sub.text}
    : ( - - ))} - - )} -
    - ) - } - - // Submenu exclusivity is decided from the list's own bubble, by DOM: - // reaching a top-level row that is not a submenu parent — by pointer or by - // focus, data row or component row — closes the open card. A parent opens - // its card in its own handlers; rows inside the card are not top-level rows. - const collapseSubmenuFrom = (e: SyntheticEvent): void => { - const row = e.target instanceof Element ? e.target.closest('button[role="menuitem"]') : null - if (row === null || row.getAttribute('aria-haspopup') === 'menu') return - if (row.closest('[role="menu"]') !== e.currentTarget) return - setOpenSubmenuId(null) + return { + if (!menuHoverAllowed(event) || entry.disabled) return + const trigger = event.currentTarget.querySelector('button') + if (trigger !== null) submenu.openChild(entry.id, trigger) + }, + onPointerLeave: (event) => { if (menuHoverAllowed(event)) submenu.leave() }, + } : undefined} + onFocus={hasSub ? (event) => { submenu.focusParent(entry.id, event.currentTarget) } : undefined} + onClick={(event) => { + if (hasSub) submenu.openChild(entry.id, event.currentTarget, true) + else onSelect?.(entry.id) + }}>{entry.label} } const list = open && positioned && ( { event.preventDefault(); event.stopPropagation(); onClose() } : undefined} - onMouseOver={collapseSubmenuFrom} - onFocus={collapseSubmenuFrom} + onPointerOver={(event) => { if (menuHoverAllowed(event)) submenu.collapseFrom(event) }} + onFocus={submenu.collapseFrom} > + {/* Child cards anchor to this unscrolled box, which matches the card itself. */} + {submenu.entry !== undefined &&
    diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index bc94b7f090..f2757c7300 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" | 模式 | 效果 | |---|---| | `read-only` | 拒绝写入,必需 sink(如 `/dev/null`)除外 | -| `workspace-write` | 允许写入工作区根目录、显式配置的额外根目录及后端定义的临时区域 | +| `workspace-write` | 允许写入工作区根目录、受支持的额外根目录及后端定义的临时区域 | | `danger-full-access` | 绕过隔离;消费方直接 spawn 原始 argv | 强制执行逐调用报告:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示活动后端或较旧的内核 ABI 只管辖子集——Windows ACL 档与较旧的 Landlock ABI 是当前的部分强制执行情形,需要绝对边界的消费方可以拒绝或向上暴露它们。 @@ -100,7 +100,7 @@ kind: "package-reference" ### 可写根目录 -`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。 +`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。路径比较接受 Windows 上的大小写和分隔符差异。每次执行都会重新检查这些路径。Windows 子进程忽略额外根目录;文件工具仍然执行这些授权。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。
    diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index f4001be3be..6b6f314b78 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 0fa3183987..bd22dcb5d6 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -24,7 +24,7 @@ export { canonicalPath, workspaceWritableRoots, writableRoots, SandboxInvalidRoo /** * File-effect policy for confined processes. `read-only` permits only required * sinks such as `/dev/null`; `workspace-write` also permits the workspace, - * explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network + * explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network * and process visibility are outside this vocabulary. */ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -46,6 +46,7 @@ export interface SandboxExecutionPolicy { * Extra physical absolute directories `workspace-write` may also write under. * Absence means none. The enforcing host rejects missing, non-directory, and * symlinked entries on each call. Remote paths are checked on the remote host. + * Windows process confinement ignores these; harness filesystem operations honor them. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -151,14 +152,6 @@ export class SandboxUnavailableError extends HarnessError { } } -/** The selected backend is available but cannot enforce the requested policy. */ -export class SandboxUnsupportedPolicyError extends HarnessError { - constructor(detail: string) { - super(detail, 'SANDBOX_UNSUPPORTED_POLICY') - this.name = 'SandboxUnsupportedPolicyError' - } -} - declare module '@deepseek-ai/cordis' { interface Context { sandbox: SandboxProvider diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index b2f1c39c24..726f3bbfdc 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -15,7 +15,7 @@ import { realpathSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' -import { resolve } from 'node:path' +import { relative } from 'node:path' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SandboxExecutionPolicy } from './index.ts' @@ -27,7 +27,7 @@ export class SandboxInvalidRootError extends HarnessError { } } -/** Reject mutable path aliases before they can redirect an explicit grant. */ +/** Resolve existing directories, rejecting aliases that differ under the host's path comparison. */ function additionalRoot(path: string): string { let physical: string let directory: boolean @@ -38,7 +38,7 @@ function additionalRoot(path: string): string { throw new SandboxInvalidRootError(path, `cannot read the directory: ${String(error)}`) } if (!directory) throw new SandboxInvalidRootError(path, 'expected an existing directory') - if (physical !== resolve(path)) { + if (relative(path, physical) !== '') { throw new SandboxInvalidRootError(path, `use the physical directory path ${JSON.stringify(physical)}; symlinked grants are not supported`) } return physical @@ -70,18 +70,19 @@ export function canonicalPath(path: string): string { /** * The workspace-owned writable roots — the policy's workspace root plus its * explicit extra directories, as a canonical, deduplicated allow-list. Both - * the workspace and every extra root must be granted together by every - * backend; this is the one derivation of that set, kept separate from the - * platform temp policy each backend documents for itself (Seatbelt and the fs + * the workspace and every extra root are included by the filesystem fence and + * POSIX process backends; Windows process confinement ignores extra roots. + * This derivation is separate from each backend's platform temp policy (Seatbelt and the fs * fence use {@link writableRoots}; bwrap and Landlock add their own temp * grants). * @param policy - the file-effect policy to derive the allow-list from. * @returns the canonical workspace-owned roots; empty under `read-only`. - * @throws {SandboxInvalidRootError} An extra root is missing, is not a directory, or uses a symlink. + * @throws {SandboxInvalidRootError} An extra root cannot be inspected, is not a directory, or uses a symlink. */ export function workspaceWritableRoots(policy: SandboxExecutionPolicy): string[] { if (policy.mode !== 'workspace-write') return [] - return [...new Set([canonicalPath(policy.workspaceRoot), ...(policy.additionalWritableRoots ?? []).map(additionalRoot)])] + const additional = (policy.additionalWritableRoots ?? []).map(additionalRoot) + return [...new Set([canonicalPath(policy.workspaceRoot), ...additional])] } /** diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts index b7b5636882..27e2a0ae40 100644 --- a/packages/sandbox/sandbox/tests/roots.spec.ts +++ b/packages/sandbox/sandbox/tests/roots.spec.ts @@ -5,10 +5,10 @@ * additional set stays one shared derivation across backend dialects. */ -import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { canonicalPath, workspaceWritableRoots, writableRoots } from '@deepseek-ai/dsh-sandbox' /** Every temp root created by this file, removed after each test. */ @@ -52,15 +52,45 @@ describe('workspaceWritableRoots', () => { .toThrow('use the physical directory path') }) - it('rejects missing and non-directory extra roots before granting access', () => { + it.skipIf(process.platform !== 'win32')('accepts Windows casing and separator differences and retains the canonical directory identity', () => { + const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-'))) + roots.push(ws) + const extra = join(ws, 'Memory') + mkdirSync(extra) + const alias = join(ws, 'memory').replaceAll('\\', '/') + const physical = realpathSync.native(extra) + expect(alias).not.toBe(physical) + const grants = workspaceWritableRoots({ + mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [alias, extra], + }) + expect(grants).toEqual([ws, physical]) + }) + + it('rejects invalid extra roots and checks them again on each call', () => { const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-'))) roots.push(ws) const file = join(ws, 'file.txt') + const extra = join(ws, 'extra') writeFileSync(file, 'file') - expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [file] })) - .toThrow('expected an existing directory') - expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [join(ws, 'missing')] })) - .toThrow('cannot read the directory') + for (const path of [file, join(file, 'child'), extra]) { + expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [path] })) + .toThrow('Invalid additional writable directory') + } + const policy = { mode: 'workspace-write' as const, workspaceRoot: ws, additionalWritableRoots: [extra] } + mkdirSync(extra) + expect(workspaceWritableRoots(policy)).toEqual([ws, extra]) + rmSync(extra, { recursive: true }) + expect(() => workspaceWritableRoots(policy)).toThrow('cannot read the directory') + }) + + it('reports inspection failures that do not establish a missing or non-directory path', () => { + const inspect = vi.spyOn(realpathSync, 'native').mockImplementationOnce(() => { + throw Object.assign(new Error('access denied'), { code: 'EACCES' }) + }) + try { + expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: '/workspace', additionalWritableRoots: ['/private'] })) + .toThrow('cannot read the directory') + } finally { inspect.mockRestore() } }) it('read-only grants nothing even when extra roots are carried on the policy', () => { diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts index 000168e726..caba42b2a7 100644 --- a/packages/sandbox/sandbox/tests/vocabulary.spec.ts +++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts @@ -7,17 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { SANDBOX_UNAVAILABLE, SandboxUnavailableError, SandboxUnsupportedPolicyError } from '@deepseek-ai/dsh-sandbox' - -describe('SandboxUnsupportedPolicyError', () => { - it('distinguishes an unsupported policy from an unavailable backend', () => { - const error = new SandboxUnsupportedPolicyError('The selected backend cannot grant additional directories.') - expect(error).toBeInstanceOf(Error) - expect(error.name).toBe('SandboxUnsupportedPolicyError') - expect(error.code).toBe('SANDBOX_UNSUPPORTED_POLICY') - expect(error.message).toBe('The selected backend cannot grant additional directories.') - }) -}) +import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' describe('SandboxUnavailableError', () => { it('carries the structured { name, code } identity consumers key on', () => { diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index b2794baee0..e2c4ec1283 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Host-wide durable reminders with shared management and delivery to existing or new Sessions", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -39,10 +39,14 @@ "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -64,7 +68,7 @@ "dependencies": { "@deepseek-ai/schemastery": "workspace:~", "@js-temporal/polyfill": "0.5.1", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/packages/schedule/tool-schedule/package.json b/packages/schedule/tool-schedule/package.json index e5cf94d344..a169143b6d 100644 --- a/packages/schedule/tool-schedule/package.json +++ b/packages/schedule/tool-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-schedule", "description": "Model-facing scheduled task tools (schedule_create, schedule_list, schedule_view, schedule_edit) over the Host ctx.schedule service", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 4ead2e149d..8031b84912 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -22,7 +22,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 7d2eff3576..0c827943d7 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -22,7 +22,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 2b1e99bc7f..7b8215d860 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index b4cbc02951..5b657b2d50 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -18,8 +18,8 @@ en: bb12bdeafa8571b5 zh: e7df75c03478f7ed /deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown: - en: 55c163a4ae5b8d5d - zh: c1dac154ed1fa56d + en: 259be867138d8d92 + zh: 984959dd966168bf /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 4011f686afd24f09 zh: 2710b9995c80ba1f @@ -27,8 +27,8 @@ en: 252bc78a7850557a zh: e695658a94c8c2bf /deepseek-ai-dsh-session-log-export/use-this-package/composition: - en: 4168a2d3e653ab73 - zh: 8bf955cfab7b08e2 + en: dc562975a59e0175 + zh: e8b0964a00ffef69 /deepseek-ai-dsh-session-log-export/use-this-package/configuration: en: 377a0d8860c46f63 zh: 9eb7698f813b2fa3 @@ -36,8 +36,8 @@ en: 26329688b9a924a7 zh: 94e21c10fdcfc1ac /deepseek-ai-dsh-session-log-export/use-this-package/what-to-expect: - en: 44be1f1d1903d12a - zh: c8f1eb5b92b71a30 + en: e4ad198e0e410fbf + zh: 06a815208987d006 /deepseek-ai-dsh-session-log-export/use-this-package/failures: en: e92a382bebfe99e3 zh: 671178b06808bfb9 @@ -45,11 +45,11 @@ en: 7cb82a6851405f95 zh: b8c525170d19dc6c /deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split: - en: 3bb8e18b107c353f - zh: 3cc45ce2b5699066 + en: a1a931677dabc1aa + zh: d4bd10bde23c14e9 /deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow: - en: bd46d2fa1273a6a4 - zh: 4d3eb2cf0a9bf12b + en: 00002fe0448b1654 + zh: 9d8f88e42d88fd15 /deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: en: d4e02b56ffb8ee8a zh: 0a329039788a13ee diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 58cf4006df..7409ce8c02 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -33,9 +33,9 @@ Use this package when the Web bundle should let users export a session log. It r The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. -The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back. +The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header menu disables its copy row until the operation settles; unrelated actions remain available. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back. -When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently. +When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin’s availability; both export actions remain independently available. [`ui-open-in-app`](../../client/ui-open-in-app/README.md) contributes its application submenu in a separated group after download and before feedback. ### When to choose it @@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?: name: '@deepseek-ai/dsh-session-log-export' ``` -The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, and `dsh-client-ui-layout`. The layout package declares the `shell.overlay` seat used by copy feedback; the other client packages provide the Header seat, locale, and command surface. +The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, `dsh-client-ui-context-menu`, and `dsh-client-ui-layout`. The conversation package owns the Header trigger, the context-menu service renders its shared popup, and layout declares `shell.overlay` for the download dialog and copy-feedback toast. Locale and command services supply translated copy and command acknowledgements. ### Configuration @@ -78,7 +78,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u ### What to expect -The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. +The root dialog identifies the exported Session by ID and reports preparing, download started, or failed. Switching conversations keeps it visible. Different Sessions download concurrently, while their dialogs appear one at a time in initiation order; closing one reveals the next open dialog. Closing does not cancel the download or reopen its dialog on settlement. Repeated requests for the same in-flight Session share its operation and keep its original position; a new request after settlement takes a new position. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. Attachment collection reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields remain unchanged in the exported log but do not cause attachment reads. @@ -100,11 +100,11 @@ This section explains how the package wires the export control and points at the The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download. -The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. +The shared context-menu package declares the captured `sessionHeader` target, independent of Session-row and title context actions; the conversation package owns its Header trigger. This package contributes export and optional feedback rows that observe controller, availability, and locale sources. Its download dialog and copy toast mount in the root overlay independently of the Header lifetime. ### Download flow -Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. +Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. The root component selects the earliest open entry from the controller’s Session-keyed snapshot using each operation’s initiation order; no separate dialog queue is maintained. Disposal waits for all preflights to settle and prevents a late response from starting a browser save; a download already handed to the browser remains browser-owned. The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 01df07877f..02e0badae7 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -33,9 +33,9 @@ kind: "package-reference" 格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 -剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。 +剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 菜单在复制操作结束前禁用各自的复制行;无关操作保持可用。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。 -挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 +挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。[`ui-open-in-app`](../../client/ui-open-in-app/README.zh.md) 在下载之后、反馈之前的独立分隔组中贡献应用子菜单。 ### 何时选择 @@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?: name: '@deepseek-ai/dsh-session-log-export' ``` -Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation` 和 `dsh-client-ui-layout` 一起挂载。layout 包声明复制反馈使用的 `shell.overlay` 座位;其他客户端包提供 Header 座位、locale 与命令界面。 +Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation`、`dsh-client-ui-context-menu` 和 `dsh-client-ui-layout` 一起挂载。conversation 包拥有 Header 入口,右键菜单服务渲染其共享菜单,layout 声明下载弹窗与复制反馈浮层使用的 `shell.overlay` 插槽。locale 与命令服务提供翻译文案和命令确认。 ### 配置 @@ -78,7 +78,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 预期行为 -弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后结束时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。 +根级弹窗通过 ID 标识导出的 Session,并报告准备中、开始下载或失败。切换会话后弹窗仍然保留。不同 Session 可以并行下载,弹窗按发起顺序一次显示一个;关闭后显示下一个未关闭的弹窗。关闭不会取消下载,也不会在操作结束时重新打开弹窗。同一 Session 下载进行中的重复请求共用该操作并保留原位置;操作结束后的新请求取得新的位置。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。 附件收集读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段在导出日志中保持不变,但不会触发附件读取。 @@ -100,11 +100,11 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 -Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。 +共享右键菜单包声明被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作;conversation 包拥有其 Header 入口。本包贡献导出及可选反馈行,观察控制器、可用性与语言源。下载弹窗和复制反馈浮层挂载在根 overlay 中,独立于 Header 生命周期。 ### 下载流程 -两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 +两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。根组件依据每次操作的发起顺序,从控制器按 Session 键控的快照中选择最早的未关闭条目,不维护独立弹窗队列。释放会等待所有预检结束,防止迟到响应启动浏览器保存;已经交给浏览器的下载仍由浏览器管理。 Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 92fce8c56e..36cf2027c0 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log ZIP export and Session Markdown copy", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,7 +30,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "scripts": { "bundle": "tsdown", @@ -57,12 +60,12 @@ "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-commands": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", - "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", @@ -76,10 +79,10 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", + "@deepseek-ai/dsh-client-ui-context-menu", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-layout", - "@deepseek-ai/dsh-client-ui-renderer", - "@deepseek-ai/dsh-client-ui-session" + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } diff --git a/packages/session-query/session-log-export/src/client/Dialog.tsx b/packages/session-query/session-log-export/src/client/Dialog.tsx index 58600aaf51..11cd7958ab 100644 --- a/packages/session-query/session-log-export/src/client/Dialog.tsx +++ b/packages/session-query/session-log-export/src/client/Dialog.tsx @@ -1,48 +1,53 @@ +/** Root overlay showing one Session export at a time in initiation order. */ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { Button, Modal } 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-layout/client' import type { SessionLogDownloadState } from './controller.ts' import { NS } from './locales.ts' -/** Browser operations and state injected into the Session Header contribution. */ +/** Download state and dismissal bound into the root overlay entry. */ export interface SessionLogDownloadDialogInjected { hooks: { sessionLogDownload: ObservableSnapshot } - request: (sessionId: SessionId) => Promise dismiss: (sessionId: SessionId) => void } +/** Root runtime, localized copy, and injected download state and actions. */ export type SessionLogDownloadDialogProps = - PropsRuntime<'conversation.session.header.utilities'> + PropsRuntime<'shell.overlay'> & PropsLocale & InjectFace /** - * Modal shared by the Session Header download menu item and this browser's `/export` command. - * @param props - Session runtime, bound controller state, actions, and localized copy. - * @returns the modal portal contribution. + * Show the earliest open export independently of the displayed conversation. + * @param props - root runtime, bound controller state, dismissal, and localized copy. + * @returns one modal, or null when all exports are dismissed. */ export function SessionLogDownloadDialog({ - sessionId, useSessionLogDownload, dismiss, t, + useSessionLogDownload, dismiss, t, }: SessionLogDownloadDialogProps) { - const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) - - const status = entry?.status - const open = entry?.open === true - const error = status === 'error' ? entry?.error || t('dialog.commandFailed') : null + const bySession = useSessionLogDownload(state => state.bySession) + const pending = Object.entries(bySession) + .filter((pair): pair is [string, NonNullable] => pair[1]?.open === true) + .sort((a, b) => a[1].order - b[1].order)[0] + if (pending === undefined) return null + const [id, entry] = pending + const sessionId = id as SessionId + const { status } = entry const title = status === 'downloading' ? t('dialog.preparingTitle') : status === 'success' ? t('dialog.successTitle') : t('dialog.errorTitle') const description = status === 'downloading' ? t('dialog.preparingDescription') - : status === 'success' ? t('dialog.successDescription') : error ?? t('dialog.commandFailed') + : status === 'success' ? t('dialog.successDescription') : entry.error || t('dialog.commandFailed') return ( { dismiss(sessionId) }} title={title} - description={description} + description={`${t('dialog.session', { sessionId })} ${description}`} closeLabel={t('dialog.close')} footer={} /> diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.module.css b/packages/session-query/session-log-export/src/client/HeaderAction.module.css deleted file mode 100644 index 90ca1eedbc..0000000000 --- a/packages/session-query/session-log-export/src/client/HeaderAction.module.css +++ /dev/null @@ -1,12 +0,0 @@ -/* The shared compact Button owns radius, height, and interaction fills. */ -.moreButton { - flex: none; - width: 28px; - padding: 0; - color: var(--dsw-alias-label-secondary); -} - -.moreButton svg { - width: 15px; - height: 15px; -} diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx deleted file mode 100644 index fcf0cfd862..0000000000 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from 'react' -import type { ReactNode } from 'react' -import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { - Button, IconCopyOutlineRegular, IconDownloadOutlineRegular, IconEllipsisOutlineRegular, - IconPaperPlaneOutlineRegular, Menu, -} from '@deepseek-ai/dsh-client-ui-primitives' -import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx' -import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' -import type { SessionMarkdownCopyState } from './copy-controller.ts' -import css from './HeaderAction.module.css' - -/** Markdown copy state and action bound into the Session Header contribution. */ -export interface SessionMarkdownCopyHeaderInjected { - hooks: { sessionMarkdownCopy: ObservableSnapshot } - /** - * Copy one complete Session transcript to the clipboard. - * @param sessionId - Session captured by the menu click. - */ - copyMarkdown: (sessionId: SessionId) => Promise -} - -/** Session download controls plus Markdown copy and observable feedback availability. */ -export interface SessionLogDownloadHeaderInjected - extends SessionLogDownloadDialogInjected, SessionMarkdownCopyHeaderInjected { - hooks: SessionLogDownloadDialogInjected['hooks'] - & SessionMarkdownCopyHeaderInjected['hooks'] - & { feedbackAvailable: ObservableSnapshot } - /** - * Open the existing Session feedback draft without recording feedback; no-op after the feedback plugin unloads. - * @param sessionId - Session whose feedback form to open. - */ - openFeedback: (sessionId: SessionId) => void -} - -/** Session download props plus the optional feedback action. */ -export type SessionLogDownloadHeaderProps = SessionLogDownloadDialogProps & InjectFace - -/** - * Render the Session Header menu with Markdown copy, download, and optional - * feedback actions. - * @param props - Session runtime, download and copy state, and localized copy. - * @returns the persistent Header action and Session-scoped dialog. - */ -export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderProps): ReactNode { - const { - sessionId, useSessionLogDownload, useSessionMarkdownCopy, useFeedbackAvailable, - request, copyMarkdown, openFeedback, t, - } = props - const feedbackAvailable = useFeedbackAvailable(value => value) - const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) - const copying = useSessionMarkdownCopy(state => state.copying) - const busy = entry?.status === 'downloading' - const [open, setOpen] = useState(false) - - return ( - <> - { setOpen(false) }} - items={[ - { id: 'copy', label: t('menu.copyMarkdown'), icon: , disabled: copying }, - { id: 'download', label: t('menu.download'), icon: , disabled: busy }, - ...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: }] : [], - ]} - onSelect={(id) => { - setOpen(false) - if (id === 'feedback') openFeedback(sessionId) - else if (id === 'copy') void copyMarkdown(sessionId) - else void request(sessionId) - }} - anchor={( - - )} - /> - - - ) -} diff --git a/packages/session-query/session-log-export/src/client/controller.ts b/packages/session-query/session-log-export/src/client/controller.ts index 071b99f55e..77188cfee9 100644 --- a/packages/session-query/session-log-export/src/client/controller.ts +++ b/packages/session-query/session-log-export/src/client/controller.ts @@ -1,4 +1,4 @@ -/** Browser download state shared by the Session Header button and `/export`. */ +/** Browser download operations and FIFO modal state shared by menu actions and `/export`. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -9,12 +9,14 @@ export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error' /** One Session's current download-dialog state. */ export interface SessionLogDownloadEntry { + /** Initiation order of this operation; repeated in-flight requests keep it. */ + readonly order: number readonly open: boolean readonly status: SessionLogDownloadStatus readonly error: string | null } -/** Download states keyed by the Session whose Header owns the dialog. */ +/** Authoritative download states; open entries display in initiation order across Sessions. */ export interface SessionLogDownloadState { bySession: Record } @@ -53,11 +55,12 @@ function messageOf(error: unknown): string { /** Owns one in-flight browser download per Session and publishes modal state. */ export class SessionLogDownloadController { - /** uSES-safe state source shared by every Session-scoped modal contribution. */ + /** Stable snapshot source for the root modal and menu availability. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) private readonly active = new Map }>() private disposed = false + private nextOrder = 0 /** * @param fetcher - HTTP carrier used to read the host-streamed ZIP. @@ -70,6 +73,7 @@ export class SessionLogDownloadController { /** * Download one Session tree; concurrent gestures for the same Session share one operation. + * Different Sessions run concurrently; each new operation joins the modal FIFO. * @param sessionId - root Session whose ZIP includes descendants and attachments. * @returns after the browser save starts, an error state is published, or a late post-disposal request is ignored. */ @@ -78,7 +82,7 @@ export class SessionLogDownloadController { if (existing !== undefined) return existing.done if (this.disposed) return Promise.resolve() const abort = new AbortController() - const done = this.run(sessionId, abort.signal).finally(() => { + const done = this.run(sessionId, abort.signal, this.nextOrder++).finally(() => { this.active.delete(sessionId) }) this.active.set(sessionId, { abort, done }) @@ -106,8 +110,8 @@ export class SessionLogDownloadController { await Promise.allSettled(active.map(operation => operation.done)) } - private async run(sessionId: SessionId, signal: AbortSignal): Promise { - this.publish(sessionId, { open: true, status: 'downloading', error: null }) + private async run(sessionId: SessionId, signal: AbortSignal, order: number): Promise { + this.publish(sessionId, { order, open: true, status: 'downloading', error: null }) try { const query = new URLSearchParams({ sessionId, includeDescendants: 'true' }) const route = `${SESSION_LOG_EXPORT_ROUTE}?${query.toString()}` @@ -116,13 +120,14 @@ export class SessionLogDownloadController { const detail = await response.text().catch(() => '') throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } + if (signal.aborted) return this.save(route, sessionLogZipFilename(sessionId)) const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true - this.publish(sessionId, { open, status: 'success', error: null }) + this.publish(sessionId, { order, open, status: 'success', error: null }) } catch (error: unknown) { if (signal.aborted) return const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true - this.publish(sessionId, { open, status: 'error', error: messageOf(error) }) + this.publish(sessionId, { order, open, status: 'error', error: messageOf(error) }) } } diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index 74808d1751..796541d707 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -4,15 +4,16 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-commands/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-message-feedback/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { IconCopyOutlineRegular, IconDownloadOutlineRegular, IconPaperPlaneOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadController } from './controller.ts' import { SessionMarkdownCopyController } from './copy-controller.ts' import { SessionMarkdownCopyToast, type SessionMarkdownCopyToastInjected } from './CopyToast.tsx' -import { SessionLogDownloadHeaderAction, type SessionLogDownloadHeaderInjected } from './HeaderAction.tsx' +import { SessionLogDownloadDialog, type SessionLogDownloadDialogInjected } from './Dialog.tsx' import { en, ja, es, fr, de, ru, pt, NS, zh, type SessionLogDownloadKey } from './locales.ts' declare module '@deepseek-ai/cordis' { @@ -29,11 +30,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export type { SessionLogDownloadEntry, SessionLogDownloadState } from './controller.ts' -export const inject = ['slots', 'locale'] +export const inject = ['slots', 'locale', 'contextMenus'] /** * Provide the download controller and mount its modal, the Markdown copy - * controller and its root toast, and both Header actions. + * controller and its root toast, and the shared Session menu actions. * @param ctx - browser context carrying slots and locale services. */ export function apply(ctx: ClientContext): void { @@ -50,6 +51,34 @@ export function apply(ctx: ClientContext): void { return () => { feedbackAvailable.set(false) } }, 'session-log-download: feedback availability') }) + const t = ctx.locale.bind(NS) + ctx.slots.inject('contextMenu.icon', function* () { + for (const [key, Icon] of Object.entries({ + 'session-log-download.copy': IconCopyOutlineRegular, + 'session-log-download.download': IconDownloadOutlineRegular, + 'session-log-download.feedback': IconPaperPlaneOutlineRegular, + })) yield ctx.slots.register({ name: 'contextMenu.icon', key }, Icon) + }) + ctx.effect(() => ctx.contextMenus.register({ + id: 'session-log-download.export', target: 'sessionHeader', order: 100, + observe: () => [controller.store, copyController.store, ctx.locale], + resolve: ({ sessionId }) => [ + { id: 'copy', label: t('menu.copyMarkdown'), icon: { key: 'session-log-download.copy' }, + disabled: copyController.store.getSnapshot().copying, run: () => copyController.copy(sessionId) }, + { id: 'download', label: t('menu.download'), icon: { key: 'session-log-download.download' }, + disabled: controller.store.getSnapshot().bySession[sessionId]?.status === 'downloading', + run: () => controller.download(sessionId) }, + ], + }), 'session-log-download: Header exports') + ctx.effect(() => ctx.contextMenus.register({ + id: 'session-log-download.feedback', target: 'sessionHeader', order: 600, + observe: () => [feedbackAvailable, ctx.locale], + resolve: ({ sessionId }) => feedbackAvailable.getSnapshot() ? [{ + id: 'feedback', label: t('menu.feedback'), icon: { key: 'session-log-download.feedback' }, separatorBefore: true, + // The feedback plugin can unload between menu preparation and selection. + run: () => { ctx.get('feedbackUi')?.openSession(sessionId) }, + }] : [], + }), 'session-log-download: Header feedback') ctx.on('command/executed', (sessionId, commandName, result) => { if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId) }) @@ -62,23 +91,15 @@ export function apply(ctx: ClientContext): void { dismiss: () => { copyController.dismiss() }, }), }, SessionMarkdownCopyToast)) - ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ - name: 'conversation.session.header.utilities', + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'session-log-download', locale: NS, - inject: (): SessionLogDownloadHeaderInjected => ({ - hooks: { - sessionLogDownload: controller.store, - sessionMarkdownCopy: copyController.store, - feedbackAvailable, - }, - request: (sessionId: SessionId) => controller.download(sessionId), - copyMarkdown: (sessionId: SessionId) => copyController.copy(sessionId), + inject: (): SessionLogDownloadDialogInjected => ({ + hooks: { sessionLogDownload: controller.store }, dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, - // The feedback plugin can unload between the menu render and this click. - openFeedback: (sessionId: SessionId) => { ctx.get('feedbackUi')?.openSession(sessionId) }, }), - }, SessionLogDownloadHeaderAction)) + }, SessionLogDownloadDialog)) } export type { SessionLogDownloadDialogInjected, SessionLogDownloadDialogProps } from './Dialog.tsx' diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 516950f70c..b1a6a528e7 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -3,15 +3,15 @@ export const NS = 'session-log-download' /** Simplified-Chinese Session export strings. */ export const zh = { - 'header.more': '更多操作', 'menu.copyMarkdown': '复制会话为 Markdown', 'menu.download': '下载 Session 日志', 'menu.feedback': '反馈', 'toast.copied': 'Session 已复制为 Markdown', 'toast.preparationFailed': '无法读取 Session 进行复制,请重试', 'toast.clipboardFailed': '无法写入剪贴板,请检查剪贴板权限后重试', + 'dialog.session': 'Session:{sessionId}', 'dialog.preparingTitle': '正在导出 Session', - 'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。', + 'dialog.preparingDescription': '正在准备包含指定 Session、子 Session 和附件的 ZIP 文件', 'dialog.successTitle': 'Session 导出已开始下载', 'dialog.successDescription': '浏览器正在下载 Session ZIP 文件。', 'dialog.errorTitle': 'Session 导出失败', @@ -21,15 +21,15 @@ export const zh = { /** English Session export strings. */ export const en: Record = { - 'header.more': 'More actions', 'menu.copyMarkdown': 'Copy Session as Markdown', 'menu.download': 'Download session log', 'menu.feedback': 'Feedback', 'toast.copied': 'Session copied as Markdown.', 'toast.preparationFailed': 'Could not read the Session for copying. Try again.', 'toast.clipboardFailed': 'Could not write to the clipboard. Check clipboard permission and try again.', + 'dialog.session': 'Session: {sessionId}', 'dialog.preparingTitle': 'Exporting Session', - 'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.', + 'dialog.preparingDescription': 'Preparing a ZIP containing the selected Session, its sub-Sessions, and attachments.', 'dialog.successTitle': 'Session download started', 'dialog.successDescription': 'The browser is downloading the Session ZIP.', 'dialog.errorTitle': 'Session export failed', @@ -42,15 +42,15 @@ export type SessionLogDownloadKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { - 'header.more': 'その他の操作', 'menu.copyMarkdown': 'セッションを Markdown としてコピー', 'menu.download': 'セッションログをダウンロード', 'menu.feedback': 'フィードバック', 'toast.copied': 'セッションを Markdown としてコピーしました', 'toast.preparationFailed': 'コピーするセッションを読み取れませんでした。再試行してください。', 'toast.clipboardFailed': 'クリップボードに書き込めませんでした。クリップボードの権限を確認して再試行してください。', + 'dialog.session': 'セッション: {sessionId}', 'dialog.preparingTitle': 'セッションをエクスポート中', - 'dialog.preparingDescription': 'このセッション、そのサブセッション、添付ファイルを含むZIPを準備しています。', + 'dialog.preparingDescription': '指定のセッション、そのサブセッション、添付ファイルを含むZIPを準備しています。', 'dialog.successTitle': 'セッションのダウンロードを開始しました', 'dialog.successDescription': 'ブラウザがセッションZIPをダウンロードしています。', 'dialog.errorTitle': 'セッションのエクスポートに失敗しました', @@ -60,15 +60,15 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - 'header.more': 'Más acciones', 'menu.copyMarkdown': 'Copiar sesión como Markdown', 'menu.download': 'Descargar registro de sesión', 'menu.feedback': 'Comentarios', 'toast.copied': 'Sesión copiada como Markdown.', 'toast.preparationFailed': 'No se pudo leer la sesión para copiarla. Intentar otra vez.', 'toast.clipboardFailed': 'No se pudo escribir en el portapapeles. Verifique los permisos del portapapeles e inténtelo nuevamente.', + 'dialog.session': 'Sesión: {sessionId}', 'dialog.preparingTitle': 'Sesión de exportación', - 'dialog.preparingDescription': 'Preparar un ZIP que contenga esta Sesión, sus subsesiones y archivos adjuntos.', + 'dialog.preparingDescription': 'Preparar un ZIP que contenga la sesión seleccionada, sus subsesiones y archivos adjuntos.', 'dialog.successTitle': 'Descarga de sesión iniciada', 'dialog.successDescription': 'El navegador está descargando el ZIP de la sesión.', 'dialog.errorTitle': 'Error al exportar la sesión', @@ -78,15 +78,15 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - 'header.more': 'Autres actions', 'menu.copyMarkdown': 'Copier la session sous Markdown', 'menu.download': 'Télécharger le journal de session', 'menu.feedback': 'Commentaires', 'toast.copied': 'Session copiée sous le nom Markdown.', 'toast.preparationFailed': 'Impossible de lire la session pour la copier. Essayer à nouveau.', 'toast.clipboardFailed': "Impossible d'écrire dans le presse-papiers. Vérifiez l'autorisation du presse-papiers et réessayez.", + 'dialog.session': 'Session : {sessionId}', 'dialog.preparingTitle': "Session d'exportation", - 'dialog.preparingDescription': "Préparation d'un ZIP contenant cette session, ses sous-sessions et les pièces jointes.", + 'dialog.preparingDescription': "Préparation d'un ZIP contenant la session sélectionnée, ses sous-sessions et les pièces jointes.", 'dialog.successTitle': 'Le téléchargement de la session a commencé', 'dialog.successDescription': 'Le navigateur télécharge le ZIP de la session.', 'dialog.errorTitle': "Échec de l'exportation de la session", @@ -96,15 +96,15 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - 'header.more': 'Weitere Aktionen', 'menu.copyMarkdown': 'Kopieren Sie die Sitzung als Markdown', 'menu.download': 'Sitzungsprotokoll herunterladen', 'menu.feedback': 'Rückmeldung', 'toast.copied': 'Sitzung als Markdown kopiert.', 'toast.preparationFailed': 'Die Sitzung zum Kopieren konnte nicht gelesen werden. Versuchen Sie es erneut.', 'toast.clipboardFailed': 'Es konnte nicht in die Zwischenablage geschrieben werden. Überprüfen Sie die Berechtigung für die Zwischenablage und versuchen Sie es erneut.', + 'dialog.session': 'Sitzung: {sessionId}', 'dialog.preparingTitle': 'Sitzung exportieren', - 'dialog.preparingDescription': 'Vorbereiten einer ZIP-Datei mit dieser Sitzung, ihren Untersitzungen und Anhängen.', + 'dialog.preparingDescription': 'Vorbereiten einer ZIP-Datei mit der ausgewählten Sitzung, ihren Untersitzungen und Anhängen.', 'dialog.successTitle': 'Sitzungsdownload gestartet', 'dialog.successDescription': 'Der Browser lädt die Sitzungs-ZIP herunter.', 'dialog.errorTitle': 'Der Sitzungsexport ist fehlgeschlagen', @@ -114,15 +114,15 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - 'header.more': 'Другие действия', 'menu.copyMarkdown': 'Скопировать сеанс как Markdown', 'menu.download': 'Скачать журнал сеанса', 'menu.feedback': 'Обратная связь', 'toast.copied': 'Сессия скопирована как Markdown.', 'toast.preparationFailed': 'Не удалось прочитать сеанс для копирования. Попробуйте еще раз.', 'toast.clipboardFailed': 'Не удалось записать в буфер обмена. Проверьте разрешение буфера обмена и повторите попытку.', + 'dialog.session': 'Сеанс: {sessionId}', 'dialog.preparingTitle': 'Экспорт сеанса', - 'dialog.preparingDescription': 'Подготовка ZIP-файла, содержащего этот сеанс, его подсеансы и вложения.', + 'dialog.preparingDescription': 'Подготовка ZIP-файла, содержащего выбранный сеанс, его подсеансы и вложения.', 'dialog.successTitle': 'Загрузка сеанса началась', 'dialog.successDescription': 'Браузер загружает ZIP-файл сеанса.', 'dialog.errorTitle': 'Экспорт сеанса не удался', @@ -132,15 +132,15 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - 'header.more': 'Mais ações', 'menu.copyMarkdown': 'Copiar sessão como Markdown', 'menu.download': 'Baixar registro da sessão', 'menu.feedback': 'Comentários', 'toast.copied': 'Sessão copiada como Markdown.', 'toast.preparationFailed': 'Não foi possível ler a sessão para cópia. Tente novamente.', 'toast.clipboardFailed': 'Não foi possível gravar na área de transferência. Verifique a permissão da área de transferência e tente novamente.', + 'dialog.session': 'Sessão: {sessionId}', 'dialog.preparingTitle': 'Exportando Sessão', - 'dialog.preparingDescription': 'Preparando um ZIP contendo esta Sessão, suas subsessões e anexos.', + 'dialog.preparingDescription': 'Preparando um ZIP contendo a sessão selecionada, suas subsessões e anexos.', 'dialog.successTitle': 'Download da sessão iniciado', 'dialog.successDescription': 'O navegador está baixando o ZIP da sessão.', 'dialog.errorTitle': 'Falha na exportação da sessão', diff --git a/packages/session-query/session-log-export/src/css-modules.d.ts b/packages/session-query/session-log-export/src/css-modules.d.ts deleted file mode 100644 index bc5e482353..0000000000 --- a/packages/session-query/session-log-export/src/css-modules.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} - -declare module '*.css' diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index f8ed7792bf..ed14ac8247 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -1,14 +1,15 @@ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' +import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { SessionMarkdownCopyToast } from '../src/client/CopyToast.tsx' import type { SessionMarkdownCopyToastInjected } from '../src/client/CopyToast.tsx' -import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' +import { SessionLogDownloadDialog, type SessionLogDownloadDialogInjected } from '../src/client/Dialog.tsx' import { apply, inject } from '../src/client/index.ts' -import type { SessionLogDownloadHeaderInjected } from '../src/client/HeaderAction.tsx' const SID = 'session-export-apply' as SessionId @@ -24,6 +25,7 @@ function declare(slots: SlotRegistry): () => void { 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, 'shell.overlay': { kind: 'list', scope: 'root' }, + 'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true }, }, } as never, () => null) } @@ -35,47 +37,60 @@ function injectedOf(entry: { inject?: ((...args: never[]) => object) | undefined async function bench() { const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SlotRegistry).await() const slots = ctx.get('slots') as SlotRegistry const declaration = declare(slots) ctx.provide('locale', new LocaleRuntime(ctx)) + ctx.locale.setLocale('en') + const failed = vi.fn(), registry = new MenuRegistry(failed) + onTestFinished(() => { registry.dispose() }) + const active = createSnapshotStore(null) + const open = vi.fn((target: ContextMenuTarget) => { active.set(target) }) + const close = vi.fn(() => { active.set(null) }) + ctx.provide('contextMenus', { active, open, close, register: registry.register.bind(registry) } as never) + const menu = () => registry.resolve({ kind: 'sessionHeader', value: { sessionId: SID } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, declaration, fiber } + return { ctx, slots, declaration, fiber, registry, menu, active, open, close, failed } } describe('session-log-download browser plugin', () => { - it('follows feedback plugin availability and opens the selected Session form', async () => { + it('follows feedback plugin availability without adding Header actions to Session context menus', async () => { const b = await bench() - const entry = b.slots.entries('conversation.session.header.utilities')[0]! - const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected - expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) - injected.openFeedback(SID) - + const menu = b.menu() + expect(menu.source.getSnapshot().map(row => row.label)).toEqual(['Copy Session as Markdown', 'Download session log']) const openSession = vi.fn() const feedback = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession }) }) await feedback.await() - await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(true) }) - injected.openFeedback(SID) + await vi.waitFor(() => { expect(menu.source.getSnapshot().map(row => row.label)).toEqual(['Copy Session as Markdown', 'Download session log', 'Feedback']) }) + b.menu().select('session-log-download.feedback/feedback') expect(openSession).toHaveBeenCalledWith(SID) - await feedback.dispose() - await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) }) - expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(1) + await vi.waitFor(() => { expect(menu.source.getSnapshot()).toHaveLength(2) }) + const session = b.registry.resolve({ kind: 'session', value: { sessionId: SID, displayTitle: 'Captured' } }) + expect(session.source.getSnapshot()).toEqual([]) + const reloaded = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession }) }) + await reloaded.await() + await vi.waitFor(() => { expect(menu.source.getSnapshot()).toHaveLength(3) }) + b.menu().select('session-log-download.feedback/feedback') + expect(openSession).toHaveBeenCalledTimes(2) await b.fiber.dispose() + expect(menu.source.getSnapshot()).toEqual([]) }) - it('provides one controller and removes its Header and overlay contributions on disposal', async () => { + it('provides one controller and root overlays without contributing a Header trigger', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))) const b = await bench() - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'contextMenus']) expect(b.ctx.sessionLogDownload).toBeDefined() expect(b.slots.entries('conversation.session.header.actions')).toHaveLength(0) - const entry = b.slots.entries('conversation.session.header.utilities')[0] - expect(entry?.component).toBe(SessionLogDownloadHeaderAction) + expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) + const entry = b.slots.entries('shell.overlay').find(row => row.options.id === 'session-log-download') + expect(entry?.component).toBe(SessionLogDownloadDialog) expect(entry?.options).toMatchObject({ id: 'session-log-download' }) - const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected - await injected.request(SID) + const injected = injectedOf(entry) as SessionLogDownloadDialogInjected + await b.ctx.sessionLogDownload.download(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error') injected.dismiss(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.open).toBe(false) @@ -84,16 +99,49 @@ describe('session-log-download browser plugin', () => { expect(overlay?.component).toBe(SessionMarkdownCopyToast) expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) const overlayInjected = injectedOf(overlay) as SessionMarkdownCopyToastInjected - await injected.copyMarkdown(SID) - expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') + b.menu().select('session-log-download.export/copy') + await vi.waitFor(() => { expect(overlayInjected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') }) overlayInjected.dismiss() - expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull() + expect(overlayInjected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull() + const retained = b.menu() await b.fiber.dispose() + expect(retained.source.getSnapshot()).toEqual([]) + expect(b.menu().source.getSnapshot()).toEqual([]) + expect(b.slots.entries('contextMenu.icon')).toHaveLength(0) expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) expect(b.slots.entries('shell.overlay')).toHaveLength(0) }) + it('updates only occupied export rows and keeps feedback available during downloads and copy', async () => { + const download = Promise.withResolvers(), copy = Promise.withResolvers() + const fetcher = vi.fn((input: string | URL) => String(input).includes('session.markdown') ? copy.promise : download.promise) + vi.stubGlobal('fetch', fetcher) + const b = await bench() + const feedback = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession: vi.fn() }) }) + await feedback.await() + const menu = b.menu() + b.menu().select('session-log-download.export/download') + expect(menu.source.getSnapshot().map(row => [row.label, row.disabled === true])).toEqual([ + ['Copy Session as Markdown', false], ['Download session log', true], ['Feedback', false], + ]) + b.menu().select('session-log-download.export/download') + expect(fetcher).toHaveBeenCalledOnce() + b.menu().select('session-log-download.export/copy') + expect(menu.source.getSnapshot().map(row => row.disabled === true)).toEqual([true, true, false]) + b.menu().select('session-log-download.export/copy') + expect(fetcher).toHaveBeenCalledTimes(2) + download.resolve(new Response('', { status: 500 })) + copy.resolve(new Response('', { status: 500 })) + await vi.waitFor(() => { expect(menu.source.getSnapshot().every(row => !row.disabled)).toBe(true) }) + expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]).toMatchObject({ open: true, status: 'error' }) + const face = injectedOf(b.slots.entries('shell.overlay')[0]) as SessionMarkdownCopyToastInjected + expect(face.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') + expect(b.failed).not.toHaveBeenCalled() + b.ctx.locale.setLocale('zh') + expect(menu.source.getSnapshot()[0]?.label).not.toBe('Copy Session as Markdown') + }) + it('downloads only for an export execution acknowledged by this browser client', async () => { const fetcher = vi.fn(async () => new Response('', { status: 500 })) vi.stubGlobal('fetch', fetcher) @@ -115,14 +163,15 @@ describe('session-log-download browser plugin', () => { await second.fiber.dispose() }) - it('re-registers after the declaring Header slot collapses and returns', async () => { + it('re-registers root overlays after their declaring slot collapses and returns', async () => { const b = await bench() b.declaration() expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) expect(b.slots.entries('shell.overlay')).toHaveLength(0) const redeclare = declare(b.slots) await Promise.resolve() - expect(b.slots.entries('conversation.session.header.utilities')[0]?.component).toBe(SessionLogDownloadHeaderAction) + expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) + expect(b.slots.entries('shell.overlay')[1]?.component).toBe(SessionLogDownloadDialog) expect(b.slots.entries('shell.overlay')[0]?.component).toBe(SessionMarkdownCopyToast) redeclare() await b.fiber.dispose() diff --git a/packages/session-query/session-log-export/tests/controller.client.spec.ts b/packages/session-query/session-log-export/tests/controller.client.spec.ts index b12afab861..28abd47b53 100644 --- a/packages/session-query/session-log-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/controller.client.spec.ts @@ -30,7 +30,7 @@ describe('SessionLogDownloadController', () => { 'dsh-session-session-export-controller.zip', ) expect(controller.store.getSnapshot().bySession[SID]).toEqual({ - open: true, status: 'success', error: null, + order: 0, open: true, status: 'success', error: null, }) }) @@ -57,6 +57,7 @@ describe('SessionLogDownloadController', () => { ) await http.download(SID) expect(http.store.getSnapshot().bySession[SID]).toEqual({ + order: 0, open: true, status: 'error', error: 'Export failed: HTTP 500 backend unavailable', @@ -97,6 +98,55 @@ describe('SessionLogDownloadController', () => { await controller.dispose() }) + it('waits for every aborted preflight and never saves a late successful response', async () => { + const first = Promise.withResolvers(), second = Promise.withResolvers() + const signals: AbortSignal[] = [] + const save = vi.fn() + const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => { + if (init?.signal === undefined || init.signal === null) throw new Error('Missing cancellation signal') + signals.push(init.signal) + return signals.length === 1 ? first.promise : second.promise + }) + const controller = new SessionLogDownloadController(fetcher, save) + const a = controller.download(SID) + const b = controller.download('another' as SessionId) + const beforeDisposal = controller.store.getSnapshot() + let disposed = false + const disposal = controller.dispose().then(() => { disposed = true }) + expect(signals.every(signal => signal.aborted)).toBe(true) + first.resolve(new Response('late ZIP')) + await a + expect(disposed).toBe(false) + second.reject(new Error('aborted second preflight')) + await b + await disposal + expect(disposed).toBe(true) + expect(save).not.toHaveBeenCalled() + expect(controller.store.getSnapshot()).toBe(beforeDisposal) + }) + + it('keeps per-operation initiation order across concurrent Sessions and repeated requests', async () => { + const response = Promise.withResolvers() + const fetcher = vi.fn(() => response.promise) + const save = vi.fn() + const controller = new SessionLogDownloadController(fetcher, save) + const first = controller.download(SID) + const other = controller.download('another' as SessionId) + const initial = controller.store.getSnapshot() + expect(initial.bySession[SID]?.order).toBe(0) + expect(initial.bySession.another?.order).toBe(1) + controller.dismiss(SID) + expect(controller.download(SID)).toBe(first) + expect(controller.store.getSnapshot().bySession[SID]).toMatchObject({ open: false, order: 0 }) + response.resolve(new Response('zip')) + await Promise.all([first, other]) + expect(save).toHaveBeenCalledTimes(2) + await controller.download(SID) + expect(controller.store.getSnapshot().bySession[SID]).toMatchObject({ open: true, order: 2 }) + expect(controller.store.getSnapshot().bySession.another?.order).toBe(1) + await controller.dispose() + }) + it('requests the document-relative route through the default carrier', async () => { const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip')) vi.stubGlobal('fetch', fetcher) diff --git a/packages/session-query/session-log-export/tests/dialog.client.spec.tsx b/packages/session-query/session-log-export/tests/dialog.client.spec.tsx index fef9adbbdf..949b6bea1c 100644 --- a/packages/session-query/session-log-export/tests/dialog.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/dialog.client.spec.tsx @@ -1,76 +1,134 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useSyncExternalStore } from 'react' +import { bindSnapshotSelector, contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionLogDownloadDialog } from '../src/client/Dialog.tsx' import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx' -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' const SID = 'session-export-dialog' as SessionId +const OTHER = 'other-session' as SessionId +const unused = (): never => { throw new Error('Root export dialog does not consume Session hooks') } +const standard: GlobalStandardProps = { + useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, + useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, + usePanelInfo: unused, useContextMenu: contextMenuHook(), useResource: unused, useWorkspaces: unused, +} function bench( - controller = new SessionLogDownloadController( - async () => new Response('zip', { status: 200 }), vi.fn(), - ), + controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()), + releasePending: () => void = () => {}, ) { + onTestFinished(async () => { releasePending(); await controller.dispose() }) const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) }) - function useSessionLogDownload(selector: (state: ReturnType) => T): T { - return useSyncExternalStore( - listener => controller.store.subscribe(listener), - () => selector(controller.store.getSnapshot()), - ) + const props: SessionLogDownloadDialogProps = { + ...standard, useSessionLogDownload: bindSnapshotSelector(controller.store), dismiss, t: makeTranslate(en), } - const t = (key: keyof typeof en): string => en[key] - const props = { sessionId: SID, useSessionLogDownload, dismiss, t } as unknown as SessionLogDownloadDialogProps const view = render() - return { controller, dismiss, view } + return { controller, dismiss, props, view } +} + +function close(view: ReturnType) { + const button = view.getAllByRole('button', { name: 'Close' }).at(-1) + if (button === undefined) throw new Error('Export dialog has no close button') + fireEvent.click(button) } afterEach(cleanup) describe('SessionLogDownloadDialog', () => { - it('shows a controller failure and closes it without reading Session history', async () => { - const b = bench() - act(() => { - b.controller.store.set({ - bySession: { [SID]: { open: true, status: 'error', error: 'toolbar failed' } }, - }) - }) - const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' }) - expect(dialog.textContent).toContain('toolbar failed') - const close = b.view.getAllByRole('button', { name: 'Close' })[0] - if (close === undefined) throw new Error('Session export dialog has no close button') - fireEvent.click(close) - await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) }) + it('shows a controller failure with the exported Session identity and closes it', async () => { + const b = bench(new SessionLogDownloadController(async () => { throw new Error('transport failed') }, vi.fn())) + await act(() => b.controller.download(SID)) + const dialog = b.view.getByRole('dialog', { name: 'Session export failed' }) + expect(dialog.textContent).toContain(`Session: ${SID}`) + expect(dialog.textContent).toContain('transport failed') + fireEvent.click(b.view.getAllByRole('button', { name: 'Close' })[0]!) + expect(b.dismiss).toHaveBeenCalledWith(SID) + expect(b.view.queryByRole('dialog')).toBeNull() }) - it('renders the in-flight state and the settled browser download state', async () => { - let release!: (response: Response) => void - const pending = new Promise((resolve) => { release = resolve }) - const controller = new SessionLogDownloadController(() => pending, vi.fn()) - const b = bench(controller) - - const download = controller.download(SID) - expect(await b.view.findByRole('dialog', { name: 'Exporting Session' })).toBeTruthy() - release(new Response('zip', { status: 200 })) - await download - expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy() + it('keeps one FIFO modal while Sessions download in parallel and settle out of order', async () => { + const first = Promise.withResolvers(), second = Promise.withResolvers() + const save = vi.fn() + const fetcher = vi.fn((input: string | URL) => String(input).includes(OTHER) ? second.promise : first.promise) + const b = bench(new SessionLogDownloadController(fetcher, save), () => { + first.resolve(new Response('zip')) + second.resolve(new Response('zip')) + }) + let a!: Promise, duplicate!: Promise, other!: Promise + act(() => { a = b.controller.download(SID); other = b.controller.download(OTHER); duplicate = b.controller.download(SID) }) + expect(duplicate).toBe(a) + expect(fetcher).toHaveBeenCalledTimes(2) + expect(b.view.getAllByRole('dialog')).toHaveLength(1) + expect(b.view.getByRole('dialog').textContent).toContain(SID) + await act(async () => { second.resolve(new Response('zip')); await other }) + expect(b.view.getByRole('dialog', { name: 'Exporting Session' }).textContent).toContain(SID) + close(b.view) + expect(b.view.getByRole('dialog', { name: 'Session download started' }).textContent).toContain(OTHER) + await act(async () => { first.resolve(new Response('zip')); await a }) + expect(save).toHaveBeenCalledTimes(2) + expect(b.view.getByRole('dialog').textContent).toContain(OTHER) + close(b.view) + expect(b.view.queryByRole('dialog')).toBeNull() }) - it('uses fallback copy when a failure has no detail', async () => { + it('orders a new operation after existing open exports rather than historical Record insertion', async () => { + const b = bench() + await act(() => b.controller.download(SID)) + close(b.view) + await act(() => b.controller.download(OTHER)) + await act(() => b.controller.download(SID)) + expect(b.view.getByRole('dialog').textContent).toContain(OTHER) + close(b.view) + expect(b.view.getByRole('dialog').textContent).toContain(SID) + expect(b.controller.store.getSnapshot().bySession[SID]?.order).toBe(2) + }) + + it('retains the root modal when the displayed conversation changes and identifies errors in FIFO order', async () => { + const b = bench(new SessionLogDownloadController(async () => { throw new Error('offline') }, vi.fn())) + b.view.rerender(<>
    {SID}
    ) + await act(() => b.controller.download(SID)) + const dialog = b.view.getByRole('dialog') + b.view.rerender(<>
    {OTHER}
    ) + expect(b.view.getByRole('dialog')).toBe(dialog) + expect(dialog.textContent).toContain(SID) + expect(dialog.textContent).not.toContain(OTHER) + await act(() => b.controller.download(OTHER)) + close(b.view) + expect(b.view.getByRole('dialog', { name: 'Session export failed' }).textContent).toContain(OTHER) + expect(b.view.getByRole('dialog').textContent).toContain('offline') + }) + + it('does not reopen a dismissed in-flight operation for repeated requests or failure', async () => { + const gate = Promise.withResolvers() + const b = bench(new SessionLogDownloadController(() => gate.promise, vi.fn()), () => { + gate.resolve(new Response('zip')) + }) + let pending!: Promise + act(() => { pending = b.controller.download(SID) }) + close(b.view) + expect(b.controller.download(SID)).toBe(pending) + expect(b.view.queryByRole('dialog')).toBeNull() + await act(async () => { gate.reject(new Error('offline')); await pending }) + expect(b.view.queryByRole('dialog')).toBeNull() + expect(b.controller.store.getSnapshot().bySession[SID]).toMatchObject({ status: 'error', open: false }) + }) + + it('uses localized fallback copy and ignores absent entries', () => { const b = bench() act(() => { - b.controller.store.set({ - bySession: { [SID]: { open: true, status: 'error', error: '' } }, - }) + b.controller.store.set({ bySession: { + missing: undefined, + [SID]: { order: 0, open: true, status: 'error', error: '' }, + } }) }) - const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' }) - expect(dialog.textContent).toContain('Could not start the Session export.') - const close = b.view.getAllByRole('button', { name: 'Close' }).at(-1) - if (close === undefined) throw new Error('Session export dialog has no footer action') - fireEvent.click(close) - await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) }) + expect(b.view.getByRole('dialog').textContent).toContain('Could not start the Session export.') + b.view.rerender() + expect(b.view.getByRole('dialog').textContent).toContain(`Session:${SID}`) + expect(b.view.getByRole('dialog').textContent).toContain(zh['dialog.commandFailed']) }) }) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx deleted file mode 100644 index 4a64ffb70d..0000000000 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ /dev/null @@ -1,199 +0,0 @@ -// @vitest-environment jsdom -import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' -import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useSyncExternalStore } from 'react' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' -import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import { SessionLogDownloadController } from '../src/client/controller.ts' -import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' -import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' -import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' -import { en } from '../src/client/locales.ts' - -const SID = 'session-export-header' as SessionId -const clipboard: SessionMarkdownClipboard = { - supportsDeferredWrite: () => false, - writeDeferred: async () => {}, - writeText: async () => true, -} -const dictionary: Record = en -const translate: SessionLogDownloadHeaderProps['t'] = key => dictionary[key] ?? key -const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } -const standard: GlobalStandardProps & SessionStandardProps = { - sessionId: SID, useSession: unused, useProjection: unused, useAccess: () => true, - useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, - useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, - useConversation: unused, useInput: unused, useChat: unused, useTrajectory: unused, - usePanelInfo: unused, useContextMenu: contextMenuHook(), useResource: unused, useWorkspaces: unused, - inputActions: { insertQuote: () => false, captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, - addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, -} - -function bindSnapshot(store: ObservableSnapshot) { - return function useSnapshot(selector: (state: State) => T): T { - return useSyncExternalStore( - listener => store.subscribe(listener), - () => selector(store.getSnapshot()), - ) - } -} - -function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController( - async () => new Response('markdown'), - clipboard, -)) { - const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()) - const request = vi.fn((sessionId: SessionId) => controller.download(sessionId)) - const copyMarkdown = vi.fn((sessionId: SessionId) => copyController.copy(sessionId)) - const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) }) - const openFeedback = vi.fn() - const feedback = createSnapshotStore(feedbackAvailable) - const useSessionLogDownload = bindSnapshot(controller.store) - const useSessionMarkdownCopy = bindSnapshot(copyController.store) - const props: SessionLogDownloadHeaderProps = { - ...standard, - useSessionLogDownload, - useSessionMarkdownCopy, - useFeedbackAvailable: bindSnapshot(feedback), - openFeedback, - request, - copyMarkdown, - dismiss, - t: translate, - } - const view = render() - return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view } -} - -afterEach(cleanup) - -describe('Session export Header action', () => { - it('opens Session feedback and closes the menu without starting a download', () => { - const b = bench(true) - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' })) - expect(b.openFeedback).toHaveBeenCalledWith(SID) - expect(b.request).not.toHaveBeenCalled() - expect(b.view.queryByRole('menu')).toBeNull() - }) - - it('keeps export available without the feedback plugin', () => { - const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - expect(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })).toBeTruthy() - expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() - }) - - it('updates the open menu when feedback becomes available, unloads, and reloads', () => { - const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - - act(() => { b.feedback.set(true) }) - expect(b.view.getByRole('menuitem', { name: 'Feedback' })).toBeTruthy() - act(() => { b.feedback.set(false) }) - expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() - - act(() => { b.feedback.set(true) }) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' })) - expect(b.openFeedback).toHaveBeenCalledWith(SID) - expect(b.request).not.toHaveBeenCalled() - expect(b.view.queryByRole('menu')).toBeNull() - }) - - it('copies Markdown from the menu and closes it without starting a download', async () => { - const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - 'Copy Session as Markdown', - 'Download session log', - ]) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })) - await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) }) - expect(b.request).not.toHaveBeenCalled() - expect(b.view.queryByRole('menu')).toBeNull() - expect(b.copyController.store.getSnapshot().notice?.kind).toBe('success') - }) - - it('disables only the copy row while a copy is pending', async () => { - const gate = Promise.withResolvers() - const copyController = new SessionMarkdownCopyController(() => gate.promise, clipboard) - const b = bench(true, copyController) - const pending = copyController.copy(SID) - expect(copyController.store.getSnapshot().copying).toBe(true) - - const button = b.view.getByRole('button', { name: 'More actions' }) - await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) - fireEvent.click(button) - const copyItem = b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) - await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) - expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) - expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) - expect(button.getAttribute('aria-busy')).toBe('true') - - gate.resolve(new Response('markdown')) - await pending - expect(b.copyController.store.getSnapshot().copying).toBe(false) - await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(false) }) - expect(button.getAttribute('aria-busy')).toBe('false') - }) - - it('opens the more-actions menu and downloads through the shared controller', async () => { - const b = bench() - const button = b.view.getByRole('button', { name: 'More actions' }) - expect(button.querySelector('svg')).not.toBeNull() - expect(button.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(button) - expect(button.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(b.view.getByRole('menuitem', { name: 'Download session log' })) - await waitFor(() => { expect(b.request).toHaveBeenCalledWith(SID) }) - expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull() - expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy() - }) - - it('closes the menu on Escape without downloading', () => { - const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() - fireEvent.keyDown(document, { key: 'Escape' }) - expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull() - expect(b.request).not.toHaveBeenCalled() - }) - - it('disables the download row while either entry path downloads this Session', async () => { - const b = bench(true) - let release!: (response: Response) => void - const pending = new Promise((resolve) => { release = resolve }) - const controller = new SessionLogDownloadController(() => pending, vi.fn()) - const useSessionLogDownload = bindSnapshot(controller.store) - const rerendered: SessionLogDownloadHeaderProps = { - ...standard, - useSessionLogDownload, - useSessionMarkdownCopy: bindSnapshot(b.copyController.store), - useFeedbackAvailable: bindSnapshot(b.feedback), - openFeedback: b.openFeedback, - request: (sessionId: SessionId) => controller.download(sessionId), - copyMarkdown: b.copyMarkdown, - dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, - t: translate, - } - b.view.rerender() - - const download = controller.download(SID) - const button = b.view.getByRole('button', { name: 'More actions' }) - await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('true') }) - fireEvent.click(button) - const item = b.view.getByRole('menuitem', { name: 'Download session log' }) - expect((item as HTMLButtonElement).disabled).toBe(true) - expect((b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) as HTMLButtonElement).disabled).toBe(false) - expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) - release(new Response('zip')) - await download - await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') }) - expect((item as HTMLButtonElement).disabled).toBe(false) - }) -}) diff --git a/packages/session-query/session-log-export/tests/root-overlay.client.spec.tsx b/packages/session-query/session-log-export/tests/root-overlay.client.spec.tsx new file mode 100644 index 0000000000..6a55f2b9b7 --- /dev/null +++ b/packages/session-query/session-log-export/tests/root-overlay.client.spec.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { act, fireEvent, screen } from '@testing-library/react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { apply, inject } from '../src/client/index.ts' + +// The test frame changes a real Session binding without depending on a feature Header. +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'test.sessionLogExport.session': { kind: 'single'; scope: 'session' } + } +} + +function SessionView({ sessionId }: PropsRuntime<'test.sessionLogExport.session'>) { + return
    {sessionId}
    +} + +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +it('keeps the export overlay across real Session rebinding and removes it when its plugin unloads', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const { ctx } = runtime + ctx.provide('locale', new LocaleRuntime(ctx)) + runtime.slots.installLocale(ctx.locale) + ctx.locale.setLocale('en') + const registry = new MenuRegistry(vi.fn()) + onTestFinished(() => { registry.dispose() }) + ctx.provide('contextMenus', { + active: createSnapshotStore(null), + open: vi.fn(), close: vi.fn(), register: registry.register.bind(registry), + } as never) + await runtime.declare({ + 'shell.overlay': { kind: 'list', scope: 'root' }, + 'test.sessionLogExport.session': { kind: 'single', scope: 'session' }, + }) + await runtime.mount({ + inject: ['slots'], + apply(scope) { scope.slots.register({ name: 'test.sessionLogExport.session' }, SessionView) }, + }) + const plugin = await runtime.mount({ apply, inject: [...inject] }) + const firstId = await runtime.sessions.add({ id: 'first-export-session' }) + const secondId = await runtime.sessions.add({ id: 'second-export-session' }) + const first = runtime.sessions.retainFor(ctx, firstId) + const second = runtime.sessions.retainFor(ctx, secondId) + await Promise.all([first.ready, second.ready]) + const session = runtime.renderSlot('test.sessionLogExport.session', {}, { session: first }) + runtime.renderSlot('shell.overlay', {}) + const gates = [Promise.withResolvers(), Promise.withResolvers()] + const signals: AbortSignal[] = [] + vi.stubGlobal('fetch', vi.fn((_input: string | URL, init?: RequestInit) => { + const gate = gates[signals.length] + if (gate === undefined || init?.signal == null) throw new Error('Unexpected preflight') + const signal = init.signal + signals.push(signal) + const abort = () => { gate.reject(signal.reason) } + signal.addEventListener('abort', abort, { once: true }) + return gate.promise.finally(() => { signal.removeEventListener('abort', abort) }) + })) + const save = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const menu = registry.resolve({ kind: 'sessionHeader', value: { sessionId: firstId } }) + act(() => { menu.select('session-log-download.export/download') }) + const modal = screen.getByRole('dialog', { name: 'Exporting Session' }) + expect(modal.textContent).toContain(firstId) + session.update({}, { session: second }) + expect(screen.getByRole('article').textContent).toBe(secondId) + expect(screen.getByRole('dialog')).toBe(modal) + expect(modal.textContent).not.toContain(secondId) + act(() => { ctx.emit('command/executed', secondId, 'export', { kind: 'success' }) }) + expect(signals).toHaveLength(2) + expect(screen.getAllByRole('dialog')).toHaveLength(1) + fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]!) + expect(signals[0]?.aborted).toBe(false) + expect(screen.getByRole('dialog').textContent).toContain(secondId) + const pendingFirst = ctx.sessionLogDownload.download(firstId) + await act(async () => { gates[0]!.resolve(new Response('zip')); await pendingFirst }) + expect(save).toHaveBeenCalledOnce() + expect(screen.getByRole('dialog').textContent).toContain(secondId) + await plugin.dispose() + expect(signals[1]?.aborted).toBe(true) + expect(screen.queryByRole('dialog')).toBeNull() + expect(menu.source.getSnapshot()).toEqual([]) + expect(runtime.slots.entries('shell.overlay')).toEqual([]) + expect(save).toHaveBeenCalledOnce() +}) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index c78dbf6893..f4d62b9943 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -8,25 +8,23 @@ "files": [ "src/client/CopyToast.tsx", "src/client/Dialog.tsx", - "src/client/HeaderAction.tsx", "src/client/controller.ts", "src/client/copy-controller.ts", "src/client/index.ts", "src/client/locales.ts", - "src/routes.ts", - "src/css-modules.d.ts" + "src/routes.ts" ], "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../client/locale/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../client/ui-commands" }, + { "path": "../../client/ui-context-menu" }, { "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-layout" }, { "path": "../../client/ui-message-feedback" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, - { "path": "../../client/ui-session" }, { "path": "../../client/ui-slots" }, { "path": "../../core/session" } ] diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index f01e37f5f1..ea5e770d95 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index d24a221093..62e97be451 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/message-quotes/package.json b/packages/session/message-quotes/package.json index b69137ae90..6ab3d52c52 100644 --- a/packages/session/message-quotes/package.json +++ b/packages/session/message-quotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-quotes", "description": "Selected-text quotation and Markdown blockquote presentation", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,7 +30,10 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 548d608865..800adff0bd 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json index 5cf54546c8..6994a9b107 100644 --- a/packages/session/session-format-catalog/package.json +++ b/packages/session/session-format-catalog/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-catalog", "description": "Build-static first-party Session format codec and migration catalog", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -28,7 +28,10 @@ "files": [ "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/session/session-format-v0-to-v1/package.json b/packages/session/session-format-v0-to-v1/package.json index 1e83b2d645..ef01975cf9 100644 --- a/packages/session/session-format-v0-to-v1/package.json +++ b/packages/session/session-format-v0-to-v1/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v0-to-v1", "description": "Frozen released-v0 Session codec and identity migration to v1", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/session/session-format-v1-to-v2/package.json b/packages/session/session-format-v1-to-v2/package.json index 1e3728debb..8336314a5a 100644 --- a/packages/session/session-format-v1-to-v2/package.json +++ b/packages/session/session-format-v1-to-v2/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v1-to-v2", "description": "Frozen released-v1 Session codec and assistant-stream migration to v2", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/session/session-format-v2-to-v3/package.json b/packages/session/session-format-v2-to-v3/package.json index 52b3869840..98f5ac07d7 100644 --- a/packages/session/session-format-v2-to-v3/package.json +++ b/packages/session/session-format-v2-to-v3/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v2-to-v3", "description": "Streaming system-prompt, canonical-envelope and PTC migration into V3", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 098cbd47da..e3480ada54 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -51,8 +51,8 @@ en: 610f35b8c44538a2 zh: 17e5c07f09783106 /deepseek-ai-dsh-session-format-v3-to-v4/native-v4-admission/lifecycle-and-reference-relationships: - en: 7dd4a236075233c7 - zh: 988e67acdd32d2ba + en: 56ddb17e1f93504d + zh: 9d9e4a74fbadeb7c /deepseek-ai-dsh-session-format-v3-to-v4/native-v4-admission/developer-changes-and-deferred-schemas: en: 471f0e087806057a zh: 76b2ee1f58118d71 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index d8133ac866..0413052e1e 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -245,7 +245,7 @@ Current common admission does not validate each user/tool/developer content bloc | `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. | | `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. | | Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. | -| Native `subagent/catalog` | Validates own version-0/version-1/version-2 payloads after the inherited cut. Each child id must be unique. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | +| Native `subagent/catalog` | Validates own version-0 through version-3 payloads after the inherited cut; version 3 is native-only and adds the child's name to a continuable or external entry. Each child id and version-3 sibling name must be unique. Names contain 1–32 lowercase letters, digits, or hyphens, start with a letter, and cannot be `lead`. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | | Inherited cut and delivery | Apply the native V4 marker, coordinate, and generation-ownership rules above. | These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 209119b521..cc0ca366fd 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -245,7 +245,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类 | `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 | | `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 | | Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 | -| 原生 `subagent/catalog` | 校验继承截点之后的自身 version-0/version-1/version-2 载荷。每个子 id 必须唯一。原生读取不收集子日志,也不比较其物理事实;继承条目不建立自身成员关系。 | +| 原生 `subagent/catalog` | 校验继承截点之后的自身 version-0 至 version-3 载荷;version 3 仅由原生写入,为 continuable 或 external 条目增加子代理名称。每个子 id 和 version-3 同级名称必须唯一。名称由 1–32 个小写字母、数字或连字符组成,以字母开头,且不能为 `lead`。原生读取不收集子日志,也不比较其物理事实;继承条目不建立自身成员关系。 | | 继承截点与投递 | 应用上文的原生 V4 标记、坐标及代际归属规则。 | 这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。 diff --git a/packages/session/session-format-v3-to-v4/package.json b/packages/session/session-format-v3-to-v4/package.json index f872194448..debebe3437 100644 --- a/packages/session/session-format-v3-to-v4/package.json +++ b/packages/session/session-format-v3-to-v4/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v3-to-v4", "description": "Streaming tool-role migration and delivery validation from Session V3 to V4", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/session/session-format-v3-to-v4/src/validation.ts b/packages/session/session-format-v3-to-v4/src/validation.ts index e2ba07e2b3..72d75b6879 100644 --- a/packages/session/session-format-v3-to-v4/src/validation.ts +++ b/packages/session/session-format-v3-to-v4/src/validation.ts @@ -2,7 +2,7 @@ import { isAbsolute } from 'node:path' import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format' -import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh-session-format' +import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' import { assertV4DeveloperData } from './developer.ts' import { assertV4LifecycleRelationships } from './relationships.ts' import { assertV4MessageSources } from './message-sources.ts' @@ -109,6 +109,7 @@ export function validateDeliveryAccepted(event: SessionFormatEvent, currentVersi */ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, knownEventTypes: ReadonlySet): void { const ids = new Set() + const names = new Set() for (const event of artifact.events) { if (!knownEventTypes.has(event.type)) continue assertV4DeveloperData(event) @@ -120,11 +121,37 @@ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, k throw new SessionFormatError('current-generation delivery marker names the wrong Session') } if (event.type === 'subagent/catalog' && event.seq >= artifact.inheritedEventCount) { - const fact = catalogFact(event.data) + const fact = nativeCatalogFact(event.data) const id = fact['childId'] as string if (ids.has(id)) throw new SessionFormatError(`duplicate catalog child ${id}`) ids.add(id) + // Sibling names are unique by construction, so a log that repeats one makes name lookup ambiguous. + if (fact['name'] === undefined) continue + const name = fact['name'] as string + if (names.has(name)) throw new SessionFormatError(`duplicate catalog child name ${name}`) + names.add(name) } } assertV4LifecycleRelationships(artifact, knownEventTypes) } + +/** + * Validate an own native V4 catalog payload: a historical version or version 3, + * which adds the child's name and is written only by native V4 creation. + * @param value - native `subagent/catalog` payload. + * @returns validated catalog payload. + */ +function nativeCatalogFact(value: SessionFormatJsonValue): SessionFormatJsonObject { + if (!isSessionFormatJsonObject(value) || value['version'] !== 3) return catalogFact(value) + if (typeof value['childId'] !== 'string' + || (value['mode'] !== 'continuable' && value['mode'] !== 'external') + || typeof value['name'] !== 'string' + || !/^[a-z][a-z0-9-]{0,31}$/u.test(value['name']) + || value['name'] === 'lead' + || (value['mode'] === 'continuable' && typeof value['label'] !== 'string') + || (value['label'] !== undefined && typeof value['label'] !== 'string')) { + throw new SessionFormatError('subagent/catalog requires a supported versioned catalog fact') + } + sessionFormatCount(value['childCreatedAt'], 'catalog child creation time') + return value +} diff --git a/packages/session/session-format-v3-to-v4/tests/codec.spec.ts b/packages/session/session-format-v3-to-v4/tests/codec.spec.ts index 21a764a44b..f9a3a510f4 100644 --- a/packages/session/session-format-v3-to-v4/tests/codec.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/codec.spec.ts @@ -169,6 +169,25 @@ it('restores external membership and rejects duplicate catalog children', () => expect(() => restore([entry, { ...entry, seq: 1 }])).toThrow('duplicate catalog child') }) +it.each(['', 'Reviewer', 'a/b', '1reviewer', 'a'.repeat(33), 'lead'])( + 'rejects invalid names in native catalog v3: %j', (name) => { + for (const mode of ['continuable', 'external']) { + const data = { version: 3, childId: 'child', childCreatedAt: 2, mode, label: 'Named', name } + expect(() => restore([{ ...catalog, data }])).toThrow('supported versioned catalog fact') + } + }, +) + +it('rejects a repeated sibling name in own catalog facts', () => { + const named = (seq: number, childId: string, name: string): SessionFormatEvent => ({ + ...catalog, seq, + data: { version: 3, childId, childCreatedAt: 2, mode: 'continuable', label: 'Named', name }, + }) + expect(restore([named(0, 'first', 'reviewer'), named(1, 'second', 'checker')]).events).toHaveLength(2) + expect(() => restore([named(0, 'first', 'reviewer'), named(1, 'second', 'reviewer')])) + .toThrow('duplicate catalog child name reviewer') +}) + it.each([ { version: 0, mode: 'external' }, { version: 1, mode: 'external' }, @@ -179,3 +198,24 @@ it.each([ const data = { ...(catalog.data as SessionFormatJsonObject), ...descriptor } expect(() => restore([{ ...catalog, data }])).toThrow('supported versioned catalog fact') }) + +it.each([ + { version: 3, mode: 'continuable', label: 'Named', name: 'reviewer' }, + { version: 3, mode: 'external', label: 'Named', name: 'auditor' }, + { version: 3, mode: 'external', name: 'auditor' }, +])('admits a native named catalog fact: %j', (descriptor) => { + const entry = { ...catalog, data: { ...(catalog.data as SessionFormatJsonObject), ...descriptor } } + expect(restore([entry]).events).toEqual([entry]) +}) + +it.each([ + { version: 3, mode: 'continuable', label: 'Named' }, + { version: 3, mode: 'continuable', name: 'reviewer' }, + { version: 3, mode: 'external', label: 1, name: 'auditor' }, + { version: 3, mode: 'one-shot', name: 'reviewer' }, + { version: 3, mode: 'unknown', name: 'reviewer' }, + { version: 3, mode: 'continuable', label: 'Named', name: 'reviewer', childId: 1 }, +])('rejects an invalid native named catalog fact: %j', (descriptor) => { + const data = { ...(catalog.data as SessionFormatJsonObject), ...descriptor } + expect(() => restore([{ ...catalog, data }])).toThrow('supported versioned catalog fact') +}) diff --git a/packages/session/session-format/package.json b/packages/session/session-format/package.json index ea40a0c2b4..05ec08a0ba 100644 --- a/packages/session/session-format/package.json +++ b/packages/session/session-format/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format", "description": "Streaming adjacent Session format migration machinery", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index a2b02fd725..86859576d5 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,7 +30,10 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 81b92306de..9435032875 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -26,7 +26,10 @@ "locale/*.json", "lib/index.js", "lib/worker.cjs", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 69d3a45cc5..b75f082c99 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index d3632dc031..a6ffad2e62 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,13 +25,16 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 92bdd362b1..d7c80bc585 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -21,8 +21,8 @@ en: e16fd6f4fcace77c zh: 4488f7f0b4751ea4 /deepseek-ai-dsh-session-projection/use-this-package/define-a-projection-unit: - en: 09758d1475fad86e - zh: 473637cb34aebe72 + en: afeb96d90f91de21 + zh: ceb09b13235bb9f3 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: en: 4ef214a05a5090b3 zh: 2119a4566d9e33eb diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index cc39a7ecc7..346de00541 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -33,7 +33,7 @@ Choose it when a domain keeps state that clients should see without re-deriving ### Define a projection unit -A domain contributes one `ProjectionDefinition` per state key: a key, a state schema, an initial state, a synchronous fold `apply(state, event)`, an optional `wire` block that projects state to a client view, and a `stateVersion` that bumps whenever the state fields or fold semantics change: +A domain contributes one `ProjectionDefinition` per state key: a key, a state schema, an initial state, a synchronous fold `apply(state, event)`, an optional `wire` block that projects state to a client view, and a `stateVersion` for checkpoint invalidation. Bump it when state fields or fold semantics change. An optional addition may retain the version only when absence preserves historical meaning and a regression test verifies that restoring an old checkpoint plus its suffix matches full replay; schema acceptance alone is insufficient: ```text const definition = { diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 82edeb2891..30b280b056 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 定义投影单元 -领域为每个状态键贡献一个 `ProjectionDefinition`:一个 key、状态 schema、初始状态、同步折叠 `apply(state, event)`、可选的 `wire` 块(把状态投影为客户端视图),以及状态字段或折叠语义变化时递增的 `stateVersion`: +领域为每个状态键贡献一个 `ProjectionDefinition`:一个 key、状态 schema、初始状态、同步折叠 `apply(state, event)`、可选的 `wire` 块(把状态投影为客户端视图),以及用于使检查点失效的 `stateVersion`。状态字段或折叠语义变化时递增版本。仅当缺失字段保留历史含义,且回归测试验证旧检查点加后缀恢复与全量回放一致时,新增可选字段才可保持版本;仅通过 schema 校验不足以保留版本: ```text const definition = { diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index fde95feaf8..0b23457bcf 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,11 +30,14 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index a3e9bc0f3b..b4aa95d5df 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -124,10 +124,10 @@ export interface ProjectionDefinition { summary?: boolean } : never /** - * Persisted-cache invalidation version: bump whenever the serialized state fields or the - * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` - * rows from an older unit are discarded instead of being forward-applied - * into garbage. Non-negative integer. + * Non-negative persisted-cache invalidation version. Bump when state fields or fold + * semantics change, except for optional additions whose absence preserves historical + * meaning and whose old-checkpoint suffix replay matches full replay. Test that + * equivalence before retaining a version; schema acceptance alone is insufficient. */ stateVersion: number } diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 6b2ea85248..6f5bcdca9d 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -34,7 +34,10 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -44,7 +47,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:~", diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 252e5b4563..36ed9d3a44 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "Feedback-authorized Session logs over byte-bounded OpenTelemetry HTTP requests", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dependencies": { diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index e8571f9375..91ae8b9c2a 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index 88acacf615..a8c6ee93ba 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -24,7 +24,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 11c4cb7a8b..a95a22c7ed 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 4c7e77e8a3..6f04527bce 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -34,7 +34,10 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -48,7 +51,7 @@ "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index d4543663bd..ef8be0cb1e 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -34,7 +34,10 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -44,7 +47,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*" }, "dependencies": { - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/session/tool-working-directory/package.json b/packages/session/tool-working-directory/package.json index 986da0943b..1366af2e7b 100644 --- a/packages/session/tool-working-directory/package.json +++ b/packages/session/tool-working-directory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-working-directory", "description": "Read and change the active Session working directory", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/working-directory/package.json b/packages/session/working-directory/package.json index 295968e6ee..e7433cb7ca 100644 --- a/packages/session/working-directory/package.json +++ b/packages/session/working-directory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-working-directory", "description": "Session working directories with durable changes and model context", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -55,6 +58,6 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.6.6" } } diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 7caaa79c1f..9f7592088d 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,7 +30,10 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index a562fcd271..d4b5947a19 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index e8f1e1216f..5a0f57d038 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index a2eb7e962a..e3be23695d 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 0f9ffe7aa1..957d6c8f77 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index 6468fd1da2..63f09470f2 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -18,8 +18,8 @@ en: 34d2f05183df200f zh: 7753b97c91743901 /deepseek-ai-dsh-shell-env/use-this-package/what-every-shell-call-receives: - en: eae86664fefc6690 - zh: 05a4e5946c8d2b87 + en: bab0b75fb967d41a + zh: 8f636d9965d01ad1 /deepseek-ai-dsh-shell-env/use-this-package/adding-your-own-environment-facts: en: 21ef223626e35905 zh: 5c468055099b0135 @@ -27,14 +27,14 @@ en: 0041438f2b7f7e4f zh: 93fa165b07adcdf4 /deepseek-ai-dsh-shell-env/use-this-package/what-can-go-wrong: - en: 42c75945b581b7e6 - zh: 6b5c487cfa28b206 + en: 737e5607cb1229cc + zh: 835b3f225d91f6ea /deepseek-ai-dsh-shell-env/understand-the-implementation: en: f782557ca8da3138 zh: e8f0082b9692cacc /deepseek-ai-dsh-shell-env/understand-the-implementation/design-philosophy: - en: 311b66ea29b5702f - zh: 3a98c2fb1fcaa519 + en: 5438a951817526d9 + zh: 66459cdea830a8c8 /deepseek-ai-dsh-shell-env/understand-the-implementation/source-map: en: 72eba3bfadcfa581 zh: dcd83e02a1910f27 @@ -51,8 +51,8 @@ en: e95e649f0410fa75 zh: b3d64877420b4c0d /deepseek-ai-dsh-shell-env/known-limitations-and-deferred-work: - en: be9888161315f026 - zh: 97d1d05c1c322596 + en: e61f29904019b35b + zh: bfe2e8363d6c7e58 /deepseek-ai-dsh-shell-env/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index fc53004803..c2507b3c7c 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -29,7 +29,7 @@ Load this plugin in any composition that mounts a model shell tool (`dsh-tool-ba ### What every shell call receives -Every call receives `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, and, for agent calls, `DSH_SESSION_ID` (the calling session's id). When the launcher provided a profile context, every call also receives `DSH_PROFILE` (the profile name) and `DSH_PROFILE_DIR` (its absolute directory; its `node_modules` holds only profile-installed packages, while the harness's own bundles resolve from the dsh installation); compositions booted without a profile omit both. +Every call receives `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, and, for agent calls, `DSH_SESSION_ID` (the calling session's id). Outside a single-executable application, every call also receives `DSH_NODE_EXECUTABLE`, the absolute path of the executable running the Harness. Under Desktop that is the Electron executable: it runs a script as Node only with `ELECTRON_RUN_AS_NODE=1`, and its Node file APIs read entries inside the `app.asar` archive that ordinary tools cannot open. When the launcher provided a profile context, every call also receives `DSH_PROFILE` (the profile name), `DSH_PROFILE_DIR` (its absolute directory; its `node_modules` holds only profile-installed packages, while the harness's own bundles resolve from the dsh installation), and `DSH_INSTALL_DIR` (the absolute directory of the `@deepseek-ai/dsh` package that anchors the installation, which carries the published `docs/` and `src/`); compositions booted without a profile omit all three. ### Adding your own environment facts @@ -64,7 +64,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What can go wrong -Two contributors declaring the same key, or a contributor claiming a reserved built-in (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`), fails plugin load loudly. A `DSH_*` key must be all-caps with underscores (for example `DSH_REGION`), and a missing description fails registration. +Two contributors declaring the same key, or a contributor claiming a reserved built-in (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`, `DSH_INSTALL_DIR`, `DSH_NODE_EXECUTABLE`), fails plugin load loudly. A `DSH_*` key must be all-caps with underscores (for example `DSH_REGION`), and a missing description fails registration. ----- @@ -80,7 +80,7 @@ This section explains the design decisions behind the registry and points at the - **Trusted namespace, rebuilt per call.** The environment is a Harness-owned `DSH_*` namespace: the shell executor discards inherited `DSH_*` values and merges the registry's current snapshot for each execution, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. - **Declared ownership, loud conflicts.** Contributors declare their keys up front so duplicate ownership is detected before the first command; resolvers may only return declared keys. -- **Built-ins stay here.** `DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, and `DSH_PROFILE_DIR` are reserved for the registry; contributors cannot claim them. +- **Built-ins stay here.** `DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`, `DSH_INSTALL_DIR`, and `DSH_NODE_EXECUTABLE` are reserved for the registry; contributors cannot claim them. ### Source map @@ -126,7 +126,7 @@ The managed environment never enters the request prefix, so it does not invalida These limits define when the registry is a poor fit or needs care. They are current package constraints, not a task backlog. -- **`list()` enumerates plugin-contributed variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. +- **`list()` enumerates plugin-contributed variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`, `DSH_INSTALL_DIR`, `DSH_NODE_EXECUTABLE`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. ### Dev Note diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index 1abd456c82..8860c11b54 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 每次 shell 调用都会收到什么 -每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent 调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。启动器提供了 profile 上下文时,每次调用还会收到 `DSH_PROFILE`(profile 名)与 `DSH_PROFILE_DIR`(其绝对目录;其 `node_modules` 只存放 profile 自行安装的包,harness 自带的组合包从 dsh 安装目录解析);不带 profile 启动的组合则两者都没有。 +每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent 调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。不在单可执行文件应用中运行时,每次调用还会收到 `DSH_NODE_EXECUTABLE`,即运行 Harness 的可执行文件的绝对路径。Desktop 下它是 Electron 可执行文件:只有设置 `ELECTRON_RUN_AS_NODE=1` 时它才以 Node 方式运行脚本,其 Node 文件 API 能读取普通工具打不开的 `app.asar` 归档内条目。启动器提供了 profile 上下文时,每次调用还会收到 `DSH_PROFILE`(profile 名)、`DSH_PROFILE_DIR`(其绝对目录;其 `node_modules` 只存放 profile 自行安装的包,harness 自带的组合包从 dsh 安装目录解析)与 `DSH_INSTALL_DIR`(作为安装锚点的 `@deepseek-ai/dsh` 包的绝对目录,随包发布的 `docs/` 与 `src/` 位于其中);不带 profile 启动的组合则三者都没有。 ### 添加你自己的环境事实 @@ -64,7 +64,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 ### 可能出什么问题 -两个 contributor 声明同一个键,或 contributor 声称拥有保留内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`),都会导致插件加载时明确报错。`DSH_*` 键必须全大写并带下划线(例如 `DSH_REGION`),缺少描述也会让注册失败。 +两个 contributor 声明同一个键,或 contributor 声称拥有保留内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`、`DSH_INSTALL_DIR`、`DSH_NODE_EXECUTABLE`),都会导致插件加载时明确报错。`DSH_*` 键必须全大写并带下划线(例如 `DSH_REGION`),缺少描述也会让注册失败。 ----- @@ -80,7 +80,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 - **受信命名空间,每次调用重建。** 环境是归 Harness 所有的 `DSH_*` 命名空间:shell 执行器丢弃继承的 `DSH_*` 值,并为每次执行合并注册表的当前快照,因此嵌套 harness 与并发的父子 agent 无法泄漏陈旧身份,`process.env` 也永不被修改。 - **声明所有权,冲突明确报错。** contributor 预先声明键,使重复所有权在第一条命令之前就被发现;resolver 只能返回已声明的键。 -- **内置键留在这里。** `DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE` 与 `DSH_PROFILE_DIR` 为注册表保留;contributor 不能声称拥有它们。 +- **内置键留在这里。** `DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`、`DSH_INSTALL_DIR` 与 `DSH_NODE_EXECUTABLE` 为注册表保留;contributor 不能声称拥有它们。 ### 源码地图 @@ -126,7 +126,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 这些限制说明注册表何时不合适或需要小心使用。它们是当前包约束,不是任务积压。 -- **`list()` 只枚举插件贡献的变量**——注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`)不包含在内,因此诊断、提示词或 UI 代码不得把 `list()` 当作完整的环境目录。 +- **`list()` 只枚举插件贡献的变量**——注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`、`DSH_INSTALL_DIR`、`DSH_NODE_EXECUTABLE`)不包含在内,因此诊断、提示词或 UI 代码不得把 `list()` 当作完整的环境目录。 ### 开发备注 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 83eb50dd05..4fb761f034 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/shell-env/src/index.ts b/packages/shell/shell-env/src/index.ts index 36752364dd..d53a393a8d 100644 --- a/packages/shell/shell-env/src/index.ts +++ b/packages/shell/shell-env/src/index.ts @@ -8,6 +8,7 @@ * @module @deepseek-ai/dsh-shell-env */ +import { dirname } from 'node:path' import { Service, type Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' @@ -73,12 +74,16 @@ const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const const DSH_PROFILE_KEY = `${DSH_ENV_PREFIX}PROFILE` as const const DSH_PROFILE_DIR_KEY = `${DSH_ENV_PREFIX}PROFILE_DIR` as const +const DSH_INSTALL_DIR_KEY = `${DSH_ENV_PREFIX}INSTALL_DIR` as const +const DSH_NODE_EXECUTABLE_KEY = `${DSH_ENV_PREFIX}NODE_EXECUTABLE` as const const RESERVED_BASH_ENV_KEYS = new Set([ DSH_HOME_ENV, DSH_SHELL_KEY, DSH_SESSION_ID_KEY, DSH_PROFILE_KEY, DSH_PROFILE_DIR_KEY, + DSH_INSTALL_DIR_KEY, + DSH_NODE_EXECUTABLE_KEY, ]) const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ @@ -158,6 +163,10 @@ export class ShellEnvRegistry extends Service { [DSH_HOME_ENV]: this.dshHome, [DSH_SHELL_KEY]: '1', } + // A single-executable application runs its embedded entry instead of a script argument. The + // browser worker image answers no `node:sea`, so the module is looked up at run time. + const sea = process.getBuiltinModule('node:sea') as typeof import('node:sea') | undefined + if (sea === undefined || !sea.isSea()) values[DSH_NODE_EXECUTABLE_KEY] = process.execPath if (execution.agent !== undefined) { values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id } @@ -165,6 +174,7 @@ export class ShellEnvRegistry extends Service { if (profile !== undefined) { values[DSH_PROFILE_KEY] = profile.name values[DSH_PROFILE_DIR_KEY] = profile.dir + values[DSH_INSTALL_DIR_KEY] = dirname(profile.installAnchor) } for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { diff --git a/packages/shell/shell-env/tests/shell-env.spec.ts b/packages/shell/shell-env/tests/shell-env.spec.ts index ad0cf49102..e14d41b06c 100644 --- a/packages/shell/shell-env/tests/shell-env.spec.ts +++ b/packages/shell/shell-env/tests/shell-env.spec.ts @@ -17,7 +17,7 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' const testToolSignal = new AbortController().signal -afterEach(() => vi.unstubAllEnvs()) +afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks() }) function execution(sessionId?: string): ToolExecution { return { @@ -46,28 +46,44 @@ describe('ShellEnvRegistry', () => { expect(registry.collect(execution())).toEqual({ DSH_HOME: resolve('./test-dsh-home'), + DSH_NODE_EXECUTABLE: process.execPath, DSH_SHELL: '1', }) expect(registry.collect(execution('session-a'))).toEqual({ DSH_HOME: resolve('./test-dsh-home'), + DSH_NODE_EXECUTABLE: process.execPath, DSH_SESSION_ID: 'session-a', DSH_SHELL: '1', }) }) - it('collects the launcher-provided profile name and directory when a profile context exists', () => { + it('omits the Node executable inside a single-executable application', () => { + vi.spyOn(process, 'getBuiltinModule').mockReturnValue({ isSea: () => true }) + expect(new ShellEnvRegistry(new Context()).collect(execution())).not.toHaveProperty('DSH_NODE_EXECUTABLE') + }) + + it('keeps the Node executable where the runtime answers no single-executable module', () => { + vi.spyOn(process, 'getBuiltinModule').mockReturnValue(undefined) + expect(new ShellEnvRegistry(new Context()).collect(execution()).DSH_NODE_EXECUTABLE).toBe(process.execPath) + }) + + it('collects the launcher-provided profile name, directory, and installation directory when a profile context exists', () => { const ctx = new Context() ctx.provide('profileContext', { name: 'web', dir: '/profiles/web', patchPath: '/profiles/web/cordis.patch.yml', installAnchor: '/dsh/package.json', cwd: '/work', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, }) const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' }) - expect(registry.collect(execution())).toMatchObject({ DSH_PROFILE: 'web', DSH_PROFILE_DIR: '/profiles/web' }) - expect(() => registry.register({ - name: 'profile-claimer', - variables: { DSH_PROFILE: { description: 'Reserved key.' } }, - resolve: () => ({}), - })).toThrow(/reserved key "DSH_PROFILE"/) + expect(registry.collect(execution())).toMatchObject({ + DSH_INSTALL_DIR: '/dsh', DSH_PROFILE: 'web', DSH_PROFILE_DIR: '/profiles/web', + }) + for (const key of ['DSH_PROFILE', 'DSH_INSTALL_DIR', 'DSH_NODE_EXECUTABLE']) { + expect(() => registry.register({ + name: `${key}-claimer`, + variables: { [key]: { description: 'Reserved key.' } }, + resolve: () => ({}), + })).toThrow(new RegExp(`reserved key "${key}"`)) + } }) it('resolves DSH_HOME from the ambient override or the user-home default', () => { diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 40fa6e7479..0cea4dc97d 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 3aa4e3e575..7690f11119 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -24,7 +24,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 084304f698..3d7102e3a9 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 415cd0504d..d3f20572f8 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -1387,6 +1387,7 @@ describe('the model-facing bash tool builds its request from named args only (no expect(bash.requests[0]?.dshEnv).toEqual({ DSH_HOME: recordingDshHome, + DSH_NODE_EXECUTABLE: process.execPath, DSH_SESSION_ID: 'request-fg', DSH_SHELL: '1', }) @@ -1413,6 +1414,7 @@ describe('the model-facing bash tool builds its request from named args only (no expect(bash.requests[0]?.env).toBeUndefined() expect(bash.requests[0]?.dshEnv).toEqual({ DSH_HOME: recordingDshHome, + DSH_NODE_EXECUTABLE: process.execPath, DSH_SESSION_ID: 'request-bg', DSH_SHELL: '1', }) @@ -1436,11 +1438,13 @@ describe('the model-facing bash tool builds its request from named args only (no expect(bash.requests.map(request => request.dshEnv)).toEqual([ { DSH_HOME: recordingDshHome, + DSH_NODE_EXECUTABLE: process.execPath, DSH_SESSION_ID: 'request-parent', DSH_SHELL: '1', }, { DSH_HOME: recordingDshHome, + DSH_NODE_EXECUTABLE: process.execPath, DSH_SESSION_ID: 'request-child', DSH_SHELL: '1', }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 964d97376b..59e41e4ae3 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -24,7 +24,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 0bcb3012c8..360190080e 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 4cf493658c..731da47a4c 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -457,6 +457,7 @@ describe('execution through the bash seam', () => { expect(request?.timeoutMs).toBe(1234) expect(request?.dshEnv).toEqual({ DSH_HOME: dshHome, + DSH_NODE_EXECUTABLE: process.execPath, DSH_SHELL: '1', DSH_SESSION_ID: 'session-1', }) diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 25e29034ad..249674af53 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/skill/skill-office/README.i18n.yaml b/packages/skill/skill-office/README.i18n.yaml index 18d051b49b..6e57a9afd3 100644 --- a/packages/skill/skill-office/README.i18n.yaml +++ b/packages/skill/skill-office/README.i18n.yaml @@ -3,38 +3,38 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-office/README.md /: - en: b65118b967447a79 - zh: 83afe7dbc1a82c44 + en: 0f4a6d0244a9d9bf + zh: c93c159987c4881d /deepseek-ai-dsh-skill-office: en: f855ac5e12b4e604 zh: b2e875d31d56cb2e /deepseek-ai-dsh-skill-office/summary: - en: 3f41e39809756048 - zh: 5ec8623eeb617755 + en: 964193f66f5fa7be + zh: 5a9a9dd9cb8575b4 /deepseek-ai-dsh-skill-office/table-of-contents: en: c361195a73cf5755 zh: 745b04e7be0be369 /deepseek-ai-dsh-skill-office/use-this-package: - en: bc0460f837676d8a - zh: be1692483d7f5daf + en: 2195e74b3648bbdc + zh: dd73de9705e5774f /deepseek-ai-dsh-skill-office/use-this-package/progressive-loading: - en: 86278b4393aa8d81 - zh: 55cfd145a7734ebd + en: 34dba830aba25713 + zh: 0d670be50fd30115 /deepseek-ai-dsh-skill-office/understand-the-implementation: - en: 6946e30f3663c72f - zh: 81dd5de9c7e61562 + en: 979cdfe0f498bc48 + zh: 1a0fa84d3a43f37b /deepseek-ai-dsh-skill-office/further-exploration: - en: 23830c100287ffb8 - zh: 7a4100e87057bc20 + en: cb17bbc5eb08d58d + zh: 8f935802afe86b12 /deepseek-ai-dsh-skill-office/model-experience: - en: bbd979fd20e7b308 - zh: 7e6b6e04f2aedb02 + en: ed08fd35aa30c97c + zh: 42e97ba44f07bcb5 /deepseek-ai-dsh-skill-office/model-experience/kv-cache-effect: - en: dc9e9205d989f1d0 - zh: 01bdbbfba743813a + en: 82649bba2b18f520 + zh: deb9b8c52d2205c6 /deepseek-ai-dsh-skill-office/known-limitations-and-deferred-work: - en: 1f718fc3bb4352d9 - zh: c1aa108ad2b05fdd + en: af3a40fdd4da52d6 + zh: 7e8a5aada81142e9 /deepseek-ai-dsh-skill-office/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 8ec3e73528ddb1a6 diff --git a/packages/skill/skill-office/README.md b/packages/skill/skill-office/README.md index a5823e7465..f5edf67d38 100644 --- a/packages/skill/skill-office/README.md +++ b/packages/skill/skill-office/README.md @@ -1,5 +1,5 @@ --- -description: "Progressive Office authoring and CLI operation skills for document workflows." +description: "Progressive Office routing and authoring skills that direct rendering, conversion, and recalculation to dedicated tools." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The catalog lists the Office router and six authoring or operation skills. Full instructions load on demand. Authoring skills use the selected Python libraries; operation skills supply the configured Node and LibreOffice Kit CLI paths. SDK and Desktop mount the skill provider and dependency query separately. +The catalog lists the Office router and three authoring skills. Full instructions load on demand. The authoring skills append the configured Python environment; the skills direct operations to [`office_render`, `office_convert`, and `office_recalculate`](../../document/tool-office/README.md), with shell copies of output files. The Web application and SDK profiles mount the provider; only Desktop, or an SDK with a primary runtime, also mounts the bundled dependency query. ## Table of Contents @@ -26,7 +26,7 @@ The catalog lists the Office router and six authoring or operation skills. Full ## Use this package -Mount the skill provider beside the skill registry and `dsh-tool-skill`. The provider supplies skill instructions; operation skills append absolute interpreter and CLI paths when loaded. +Mount the skill provider beside the skill registry and `dsh-tool-skill`. The provider supplies skill instructions; Python skills append a Python environment section. The skills direct operations to `office_render`, `office_convert`, and `office_recalculate`, so compose [`tool-office`](../../document/tool-office/README.md) beside this provider. ```yaml - name: '@deepseek-ai/dsh-skill-office' @@ -35,20 +35,21 @@ Mount the skill provider beside the skill registry and `dsh-tool-skill`. The pro | Field | Default | Meaning | |---|---|---| -| `assetRoot` | Carrier resources or packaged `assets/` | Absolute directory containing the seven skill folders. | -| `node` | Host Node.js | Standalone Node executable for operation skills; packaged carriers supply it explicitly. | -| `cli` | Installed Kit CLI | Absolute CLI entry; `false` disables operation execution. | +| `assetRoot` | Packaged `assets/` | Absolute directory containing the four skill folders. | +| `pythonEnvironment` | `'workspace-dependencies'` | Python that `office-docx`, `office-pptx`, and `office-xlsx` direct the model to; see below. | -Invalid skill assets reject registration. Authoring instructions do not resolve renderer paths; loading operation instructions validates existing interpreter and CLI files without preparing engines. Bash or PowerShell owns approvals, sandboxing, cancellation, and output management. Resources and execution tools must refer to the same host. +`pythonEnvironment` selects one fixed section appended to those three skills. Both values first defer to an explicit user or applicable AGENTS.md environment requirement. `'workspace-dependencies'` directs the model to call `load_workspace_dependencies` and use the bundled Python and libraries it returns; mount it only with `dsh-tool-workspace-dependencies`. A custom composition that mounts this package without `dsh-tool-workspace-dependencies` must configure `pythonEnvironment: user`. `'user'` directs the model to the project's or user's existing Python on PATH, to check that the required libraries import, and to name missing packages instead of installing them globally without the user's agreement. + +A relative `assetRoot` rejects registration. Catalog discovery reads each `SKILL.md`. A missing or invalid skill is omitted from the catalog with a warning, and a skill whose file disappears after discovery fails to load and leaves the catalog. Other read errors, such as a locked file, leave discovery incomplete so the registry retries it. Loading instructions resolves no executable and prepares no engine; the Office tools report an unavailable LibreOffice Kit when called. Bash or PowerShell owns the approvals, sandboxing, and cancellation of copying converted files into the workspace. ### Progressive loading -The model chooses skills from the task; the host does not inject complete instructions based on file extensions. All seven skills are listed and loadable by name through `skill` or explicit user invocation. Requested appearance changes, including spreadsheet cell fills and text alignment, require rendering even without an explicit visual-check request; an explicit request to skip rendering takes precedence. Pure operations can enter the corresponding skill directly. +The model chooses skills from the task; the host does not inject complete instructions based on file extensions. With complete resources, all four skills are listed and loadable by name through `skill` or explicit user invocation. Loading an Office skill makes the three Office tools available from the next step; when they are not in the tool list, such as after a PTC resume or fork, the skills direct the model to load `office` again. Legacy `.doc`, `.xls`, and `.ppt` files, which python-docx, openpyxl, and python-pptx cannot read, are converted with `office_convert` before editing. Requested appearance changes, including spreadsheet cell fills and text alignment, require rendering even without an explicit visual-check request, and so do new or changed slide content in presentations; an explicit request to skip rendering takes precedence. Standalone operations use the dedicated tools after routing without loading an authoring skill. PDF export alone stops after checking that the copied output is nonempty and starts with `%PDF-`. ```mermaid flowchart TD - A["Initial catalog: seven Office summaries"] + A["Initial catalog: four Office summaries"] A -->|Non-Office task| N["Continue without loading Office instructions"] A -->|Office task| B["skill(office): task routing"] @@ -56,26 +57,21 @@ flowchart TD C --> D["Author and validate content, structure and data"] D -->|No additional operation needed| F["Deliver"] - B -->|Images, visual inspection, or appearance changes| R["skill(office-render)"] - D -->|Images, visual inspection, or appearance changes| R - R --> RC["Shell: render into a private workspace temporary directory"] - RC -->|Usable preview| V["Inspect requested images"] + B -->|Images, visual inspection, appearance or slide changes| R["office_render: images in the tool result"] + D -->|Images, visual inspection, appearance or slide changes| R + R --> V["Inspect requested images"] V --> F - RC -->|Failed or blank preview| L["Report content checks and unfinished visual inspection separately"] - L --> F - B -->|Formula recalculation| X["skill(office-recalculate)"] + B -->|Formula recalculation| X["office_recalculate, shell copy"] D -->|Formula recalculation| X - X --> XC["Shell: CLI recalculate and verify values"] - XC --> F + X --> F - B -->|PDF export| P["skill(office-export-pdf)"] + B -->|PDF export| P["office_convert, shell copy"] D -->|PDF export| P - P --> PC["Shell: CLI convert"] - PC --> F + P --> F ``` -New files and content, data, or formula edits do not require rendering solely because they include incidental basic formatting. Preview images use a private, randomly named temporary directory inside the workspace and are removed after inspection; replies report the inspection in text without linking them. PDF export validates the command result and `%PDF-` signature without searching for PDF readers. Loaded instructions remain in session history after an operation finishes. The diagram describes ordinary presets across supported clients; it does not change the shell-only definition of minimal or sdk-minimal. +Documents and workbooks render only for requested images, visual inspection, or appearance changes, because their libraries verify content and Word reflows text. Presentations also render new or changed slide content, because python-pptx cannot measure text fit in fixed-size boxes; speaker-notes edits do not render. Preview images arrive in the `office_render` result, so no workspace files are created for inspection; replies report the inspection in text. Converted and recalculated files arrive as temporary paths that the model copies into the workspace with `cp` or `Copy-Item`. Loaded instructions remain in session history after an operation finishes. The diagram describes ordinary presets across supported clients; it does not change the shell-only definition of minimal or sdk-minimal. Ordinary Word text edits inspect the target runs and formatting, then reopen the saved document to verify the edit. Successful checks end validation; package and metadata investigation requires a concrete problem or an explicit request. @@ -87,15 +83,15 @@ Ordinary Word text edits inspect the target runs and formatting, then reopen the
    Implementation internals — click to expand -The provider reads descriptions at registration and bodies on demand. Registry precedence, scope, and invocation permissions apply to every skill. Each loaded skill includes its resource directory for relative references. Disposal unregisters all candidates. +The provider reads descriptions during catalog discovery and bodies on demand. Registry precedence, scope, and invocation permissions apply to every skill. Each loaded skill includes its resource directory for relative references. Disposal unregisters all candidates. -The provider registers no tools or activation projection. Loaded skill results remain logged through the generic skill loader. In PTC programs, print or return the full `tools.skill(...)` result so its instructions reach the model. Resume and fork use existing session history and tool declaration reconciliation. Interpreter and CLI paths are resolved only when an operation skill is loaded. +The provider registers no tools or activation projection. Loaded skill results remain logged through the generic skill loader. In PTC programs, print or return the full `tools.skill(...)` result so its instructions reach the model. Resume and fork use existing session history and tool declaration reconciliation. Loading a skill resolves no executable. | File | Responsibility | |---|---| -| [`src/index.ts`](src/index.ts) | Resource registration and unchanged skill bodies. | +| [`src/index.ts`](src/index.ts) | Provider registration, per-skill resource reads, and the appended Python environment section. | -| [`assets/`](assets/) | Router and six subskills. | +| [`assets/`](assets/) | Router and three authoring skills. |
    @@ -107,17 +103,18 @@ The provider registers no tools or activation projection. Loaded skill results r - [Skill registry](../skill/README.md) — discovery and precedence. - [Skill tool](../tool-skill/README.md) — model catalogs and loaded bodies. - [File delivery](../../deliverables/tool-present/README.md) — current source-path delivery. +- [Office tools](../../document/tool-office/README.md) — rendering, conversion, and recalculation. ----- ## Model Experience -Indirectly, through `dsh-tool-skill`, which renders the initial Office summary and returns routing or selected subskill instructions from `skill(name)`. +Indirectly, through `dsh-tool-skill`, which renders the initial Office summary and returns routing or selected subskill instructions from `skill(name)`. The Python skills end with a Python environment section that names either `load_workspace_dependencies` or the user's existing Python, according to `pythonEnvironment`. #### KV Cache effect -Skill results append to history without changing tool declarations, and loaded text or images remain after an operation finishes. Character estimates use `ceil(characters / 4)`: the summary is at most 50 tokens and the router body at most 200; authoring bodies are at least 35% smaller than the combined authoring and operation guides. Skill wrappers, the generic catalog, shell declarations, and `load_workspace_dependencies` are separate costs, and these estimates are not server usage. +Skill results append to history, and loaded text or images remain after an operation finishes. With `tool-office` mounted, the first successful Office skill load enables the three tool declarations on the next step. A skill omitted or removed for unreadable resources changes the catalog, which a later step publishes as a catalog update. Character estimates use `ceil(characters / 4)`: the summary is at most 50 tokens and the router body at most 200; each authoring body is at most 70% of its recorded earlier estimate: 1,688 tokens for `office-docx`, 1,879 for `office-pptx`, and 2,330 for `office-xlsx`. The appended Python environment section stays fixed while the configuration stays unchanged, and counts toward each skill body. Skill wrappers, the generic catalog, shell and Office tool declarations, and `load_workspace_dependencies` are separate costs, and these estimates are not server usage. ## Known Limitations and Deferred Work @@ -126,6 +123,9 @@ Skill results append to history without changing tool declarations, and loaded t - The provider does not install authoring libraries. - LibreOffice output can differ from native Office applications. - Legacy, encrypted, and macro-enabled files need an appropriate separate workflow. +- An omitted skill is reported only through `ctx.logger`; no product UI tells the user that installed resources are missing (tracking issue #7677). +- The `office` router body names every subskill, so it still points to a subskill omitted for missing resources; the `skill` tool reports that subskill as unavailable. +- A skill whose file is missing or whose metadata is invalid counts as a persistent failure, so the skill catalog is cached without it; restoring the file does not list the skill until the application restarts or the plugin reloads. ### Dev Note diff --git a/packages/skill/skill-office/README.zh.md b/packages/skill/skill-office/README.zh.md index 1b10195450..dbb44fc782 100644 --- a/packages/skill/skill-office/README.zh.md +++ b/packages/skill/skill-office/README.zh.md @@ -1,5 +1,5 @@ --- -description: "为文档工作流提供渐进加载的 Office 创作与 CLI 操作技能。" +description: "渐进加载的 Office 路由与创作技能,将渲染、转换和重算交给专用工具。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -目录列出 Office 路由及六个创作或操作技能,完整说明按需加载。创作技能使用选定的 Python 库;操作技能提供配置的 Node 与 LibreOffice Kit CLI 路径。SDK 和 Desktop 分别挂载技能提供者和依赖查询。 +目录列出 Office 路由及三个创作技能,完整说明按需加载。创作技能追加配置的 Python 环境说明;技能把操作交给 [`office_render`、`office_convert` 和 `office_recalculate`](../../document/tool-office/README.zh.md),并用 shell 复制输出文件。Web 应用和 SDK profile 挂载该提供者;只有 Desktop 或带主运行时的 SDK 还挂载内置依赖查询。 ## 目录 @@ -26,7 +26,7 @@ kind: "package-reference" ## 使用本包 -在技能注册表和 `dsh-tool-skill` 旁挂载技能提供者。提供者负责技能说明;操作技能在加载时附加解释器和 CLI 的绝对路径。 +在技能注册表和 `dsh-tool-skill` 旁挂载技能提供者。提供者负责技能说明;加载时,Python 技能追加 Python 环境说明。技能把操作交给 `office_render`、`office_convert` 和 `office_recalculate`,因此需在本提供者旁组合 [`tool-office`](../../document/tool-office/README.zh.md)。 ```yaml - name: '@deepseek-ai/dsh-skill-office' @@ -35,20 +35,21 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `assetRoot` | 载体资源或包内 `assets/` | 包含七个技能目录的绝对路径。 | -| `node` | 宿主 Node.js | 操作技能使用的独立 Node 可执行程序;打包载体显式提供。 | -| `cli` | 已安装 Kit CLI | CLI 入口的绝对路径;`false` 禁用操作执行。 | +| `assetRoot` | 包内 `assets/` | 包含四个技能目录的绝对路径。 | +| `pythonEnvironment` | `'workspace-dependencies'` | `office-docx`、`office-pptx` 和 `office-xlsx` 指引模型使用的 Python;见下文。 | -无效技能资源会阻止注册。创作说明不解析渲染器路径;操作说明加载时校验现有解释器和 CLI 文件,不准备引擎。Bash 或 PowerShell 负责审批、沙箱、取消和输出管理。资源与执行工具必须位于同一主机。 +`pythonEnvironment` 选择追加到这三个技能末尾的一段固定说明。两个取值都先遵守用户或适用 AGENTS.md 明确要求的环境。`'workspace-dependencies'` 指引模型调用 `load_workspace_dependencies`,使用其返回的内置 Python 与库;仅在同时挂载 `dsh-tool-workspace-dependencies` 时使用。只挂载本包而不挂载 `dsh-tool-workspace-dependencies` 的自定义组合必须配置 `pythonEnvironment: user`。`'user'` 指引模型使用项目或用户已有的 PATH 上的 Python,先检查所需库能否 import,缺少时说明需要安装的包,未经用户同意不向全局环境安装。 + +相对路径的 `assetRoot` 会阻止注册。目录发现时读取每个 `SKILL.md`。缺失或无效的技能会从目录中省略并记录警告;发现之后文件消失的技能加载失败,并从目录中移除。其他读取错误(例如文件被锁)会使本次发现不完整,由注册表重试。加载说明时不解析任何可执行文件,也不准备引擎;调用 Office 工具时才会报告 LibreOffice Kit 不可用。把转换后的文件复制进工作区时,由 Bash 或 PowerShell 负责审批、沙箱和取消。 ### 渐进加载 -模型依据任务选择技能;宿主不会根据文件扩展名注入完整说明。七个技能均列入目录,可通过 `skill` 按名称加载或由用户显式调用。用户要求的外观修改,包括电子表格单元格填充和文本对齐,即使没有明确要求视觉检查,也需要渲染;用户明确要求跳过渲染时以该要求为准。纯操作任务可以直接进入对应技能。 +模型依据任务选择技能;宿主不会根据文件扩展名注入完整说明。资源完整时,四个技能均列入目录,可通过 `skill` 按名称加载或由用户显式调用。加载 Office 技能后,三个 Office 工具从下一步开始可用;工具不在列表中时(例如 PTC 模式 resume 或 fork 之后),技能会指示模型重新加载 `office`。python-docx、openpyxl 和 python-pptx 无法读取的旧格式 `.doc`、`.xls`、`.ppt` 文件,先用 `office_convert` 转换再编辑。用户要求的外观修改,包括电子表格单元格填充和文本对齐,即使没有明确要求视觉检查,也需要渲染,演示文稿中新增或修改的幻灯片内容同样需要渲染;用户明确要求跳过渲染时以该要求为准。单独的操作任务在路由后直接调用专用工具,不加载创作技能。单独的 PDF 导出在检查复制出的文件非空且以 `%PDF-` 开头后即结束。 ```mermaid flowchart TD - A["Initial catalog: seven Office summaries"] + A["Initial catalog: four Office summaries"] A -->|Non-Office task| N["Continue without loading Office instructions"] A -->|Office task| B["skill(office): task routing"] @@ -56,26 +57,21 @@ flowchart TD C --> D["Author and validate content, structure and data"] D -->|No additional operation needed| F["Deliver"] - B -->|Images, visual inspection, or appearance changes| R["skill(office-render)"] - D -->|Images, visual inspection, or appearance changes| R - R --> RC["Shell: render into a private workspace temporary directory"] - RC -->|Usable preview| V["Inspect requested images"] + B -->|Images, visual inspection, appearance or slide changes| R["office_render: images in the tool result"] + D -->|Images, visual inspection, appearance or slide changes| R + R --> V["Inspect requested images"] V --> F - RC -->|Failed or blank preview| L["Report content checks and unfinished visual inspection separately"] - L --> F - B -->|Formula recalculation| X["skill(office-recalculate)"] + B -->|Formula recalculation| X["office_recalculate, shell copy"] D -->|Formula recalculation| X - X --> XC["Shell: CLI recalculate and verify values"] - XC --> F + X --> F - B -->|PDF export| P["skill(office-export-pdf)"] + B -->|PDF export| P["office_convert, shell copy"] D -->|PDF export| P - P --> PC["Shell: CLI convert"] - PC --> F + P --> F ``` -新建文件以及内容、数据或公式修改,不会仅因附带基础格式调整而需要渲染。预览图片保存在工作区内随机命名的私有临时目录中,并在检查后删除;回复以文字说明检查结果,不链接这些图片。PDF 导出校验命令结果和 `%PDF-` 签名,不另外查找 PDF 阅读工具。操作结束后,已加载的说明仍属于会话历史。图中描述各端常规预设,不改变 minimal 或 sdk-minimal 的纯 shell 定义。 +文档和工作簿只在要求图片、视觉检查或外观修改时渲染,因为相应的库能验证内容,而 Word 会重新排版文字。演示文稿还会渲染新增或修改的幻灯片内容,因为 python-pptx 无法测量固定大小文本框中的文字是否放得下;只修改演讲者备注时不渲染。预览图片直接出现在 `office_render` 结果中,检查不会在工作区创建文件;回复以文字说明检查结果。转换和重算的文件以临时路径返回,由模型用 `cp` 或 `Copy-Item` 复制进工作区。操作结束后,已加载的说明仍属于会话历史。图中描述各端常规预设,不改变 minimal 或 sdk-minimal 的纯 shell 定义。 普通 Word 文本编辑只检查目标文本片段及其格式,保存后重新打开文档验证修改。检查通过即结束验证;只有遇到具体问题或明确要求时才探查内部包结构和元数据。 @@ -87,15 +83,15 @@ flowchart TD
    实现细节 — 点击展开 -提供者在注册时读取摘要,按需读取正文。注册表优先级、作用域和调用权限适用于全部技能。加载结果包含用于解析相对引用的资源目录。卸载提供者会移除全部候选技能。 +提供者在目录发现时读取摘要,按需读取正文。注册表优先级、作用域和调用权限适用于全部技能。加载结果包含用于解析相对引用的资源目录。卸载提供者会移除全部候选技能。 -提供者不注册工具或激活投影。通用技能加载器记录已加载技能的结果。PTC 程序需输出或返回完整的 `tools.skill(...)` 结果,使说明进入模型上下文。恢复和 fork 使用现有会话历史及工具声明更新机制。解释器和 CLI 路径仅在加载操作技能时解析。 +提供者不注册工具或激活投影。通用技能加载器记录已加载技能的结果。PTC 程序需输出或返回完整的 `tools.skill(...)` 结果,使说明进入模型上下文。恢复和 fork 使用现有会话历史及工具声明更新机制。加载技能时不解析任何可执行文件。 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 注册资源并返回原始技能正文。 | +| [`src/index.ts`](src/index.ts) | 注册提供者、逐个读取技能资源,并追加 Python 环境说明。 | -| [`assets/`](assets/) | 路由与六个子技能。 | +| [`assets/`](assets/) | 路由与三个创作技能。 |
    @@ -107,17 +103,18 @@ flowchart TD - [技能注册表](../skill/README.zh.md) — 发现和优先级。 - [技能工具](../tool-skill/README.zh.md) — 模型目录和加载正文。 - [文件交付](../../deliverables/tool-present/README.zh.md) — 当前源文件路径交付。 +- [Office 工具](../../document/tool-office/README.zh.md) — 渲染、转换和重算。 ----- ## 模型体验 -间接影响:`dsh-tool-skill` 渲染初始 Office 摘要,并返回 `skill(name)` 选择的路由或子技能说明。 +间接影响:`dsh-tool-skill` 渲染初始 Office 摘要,并返回 `skill(name)` 选择的路由或子技能说明。Python 技能末尾的 Python 环境说明按 `pythonEnvironment` 指向 `load_workspace_dependencies` 或用户已有的 Python。 #### KV Cache 影响 -技能结果追加到历史中且不改变工具声明,操作完成后已加载文本或图片仍保留在历史中。字符估算采用 `ceil(字符数 / 4)`:摘要最多 50 token,路由正文最多 200 token;创作正文比合并后的创作与操作指南至少减少 35%。技能包装、通用目录、shell 声明和 `load_workspace_dependencies` 单独计费;这些估算不等同于服务端 usage。 +技能结果追加到历史中,操作完成后已加载文本或图片仍保留在历史中。挂载 `tool-office` 时,首次成功加载 Office 技能会在下一步启用三个工具声明。因资源不可读而省略或移除的技能会改变目录,后续步骤会将其作为目录更新发布。字符估算采用 `ceil(字符数 / 4)`:摘要最多 50 token,路由正文最多 200 token;每个创作正文最多为其记录的早期估算的 70%:`office-docx` 为 1,688 token,`office-pptx` 为 1,879 token,`office-xlsx` 为 2,330 token。配置不变时,追加的 Python 环境说明保持不变,并计入各技能正文。技能包装、通用目录、shell 与 Office 工具声明和 `load_workspace_dependencies` 单独计费;这些估算不等同于服务端 usage。 ## 已知限制与后续工作 @@ -126,6 +123,9 @@ flowchart TD - 提供者不安装创作库。 - LibreOffice 输出可能不同于原生 Office 应用。 - 旧格式、加密和启用宏的文件需要适用的独立工作流。 +- 被省略的技能只通过 `ctx.logger` 报告;产品界面不会提示用户已安装资源缺失(跟踪 issue #7677)。 +- `office` 路由正文列出全部子技能,因此仍会指向因资源缺失而被省略的子技能;`skill` 工具会报告该子技能不可用。 +- 文件缺失或元数据无效的技能被判为持久失败,技能目录会在不含它的状态下被缓存;补回文件后,需要重启应用或重新加载插件,该技能才会出现。 ### 开发备注 diff --git a/packages/skill/skill-office/assets/office-docx/SKILL.md b/packages/skill/skill-office/assets/office-docx/SKILL.md index 760a3fc45f..b52a7268fa 100644 --- a/packages/skill/skill-office/assets/office-docx/SKILL.md +++ b/packages/skill/skill-office/assets/office-docx/SKILL.md @@ -5,14 +5,18 @@ description: Create, read, edit, and validate Word documents (.docx). # Word documents -Use `python-docx` for DOCX creation and ordinary edits. Follow an explicit user or applicable AGENTS.md requirement for a project environment or another library. Otherwise call `load_workspace_dependencies` and execute the returned Python path with its bundled libraries. Do not install packages or discover a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation. +Use `python-docx` for DOCX creation and ordinary edits unless an explicit user or applicable AGENTS.md requirement names another library. Run Python as described in the Python environment section below. -Keep source scripts, intermediate files, and final documents in the task workspace; the runtime and this skill directory are read-only resources. Use the user's requested language and preserve an existing document's design unless a redesign is requested. +Keep scripts, intermediate files, and final documents in the workspace; runtime and skill resources are read-only. Use the requested language and preserve the document's design unless a redesign is requested. Name Python scripts for their task, such as `edit_docx.py`; avoid module names such as `inspect.py`, `docx.py`, or `zipfile.py` that shadow imported libraries. ## Create and edit +If `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step. + +python-docx cannot read legacy `.doc` files. Use `office_convert` with `format: "docx"`, copy the result into the workspace, then edit the copy. + For text edits, locate the target by its text or position and inspect only its runs and formatting. A title can be an ordinary paragraph without a Title style or document-property title. Update the relevant runs; assigning paragraph `.text` destroys run formatting. Save a new file unless an in-place edit is requested. For ordinary text edits, do not unzip the document, enumerate ZIP members, dump raw XML, or inspect core properties, headers, or footers unrelated to the target. Investigate these only for a concrete failure, an ambiguous target, an unsupported feature, or an explicit user requirement. Rebuilding a document can lose unsupported features. Use paragraph styles for headings and body text. Size tables for the section that contains them, and account for merged cells and nested tables. Chinese, Japanese, and Korean text may need an explicit `w:eastAsia` font assignment in addition to `run.font.name`; font names alone do not establish glyph availability or rendered appearance. @@ -33,8 +37,10 @@ python-docx does not paginate or render documents. Do not represent ordinary rep ## Check and deliver -Reopen the saved document with python-docx and verify the requested content, related formatting, and unchanged content that must be preserved. When these checks pass, stop validation and deliver the file. Do not add ZIP integrity checks, package inventories, or metadata inspection as routine final verification. +Reopen the saved document with python-docx and verify the requested content, related formatting, and unchanged content that must be preserved. These checks complete routine validation; deliver the file once they pass. ZIP integrity, package inventories, and metadata matter only for a concrete failure. -Creating a file or changing content with incidental basic formatting does not require additional operations. Load `office-render` for requested images or visual inspection. When styling, font color, spacing, sizing, or text alignment is the requested edit, load `office-render` and inspect the changed page even without a separate visual-check request, unless the user explicitly skips rendering. For PDF output load `office-export-pdf`. +Call `office_render` on the changed page when the user asks for images or visual inspection, or when styling, font color, spacing, sizing, or text alignment is the requested edit; a request to skip rendering takes precedence. New documents and content edits, with incidental basic formatting, are checked with python-docx alone. For PDF output, call `office_convert` with `format: "pdf"`, then copy the returned temporary file to the destination. -Call `present({"files":[{"path":"report.docx"}]})` with the actual final DOCX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and do not present temporary QA reports unless requested. If `present` is unavailable, provide the final workspace path using the session's supported file delivery method. +A fully transparent or uniformly blank image of populated content means the renderer failed, which ends visual checks for the task and leaves the source as it is. When content and structure checks pass, state the visual status only as "Content and structure checks passed; visual inspection was not completed." or "内容与结构检查通过;视觉检查未完成。". + +Call `present({"files":[{"path":"report.docx"}]})` with the actual final DOCX path. It exposes the current file without copying or preserving its bytes; keep it in place and omit temporary QA reports unless requested. If `present` is unavailable, deliver the final workspace path with the session's supported method. diff --git a/packages/skill/skill-office/assets/office-export-pdf/SKILL.md b/packages/skill/skill-office/assets/office-export-pdf/SKILL.md deleted file mode 100644 index 471610e2f7..0000000000 --- a/packages/skill/skill-office/assets/office-export-pdf/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: office-export-pdf -description: Convert an Office document to a requested PDF with LibreOffice Kit. ---- - -# Export an Office document to PDF - -Use the installed LibreOffice Kit CLI through the shell with the absolute Node and CLI paths supplied below. In Bash, invoke `"" ""`; in PowerShell, invoke `& "" ""`. Prefer bundled LibreOffice unless the user requests another installation. If the Office sidecar is missing, follow [sidecar setup](../office/references/sidecar.md): ask whether the user wants to download it and install only after they agree. Do not invoke system soffice, use npx, or install a replacement to work around a failed conversion with an installed sidecar. - -Convert the final saved Office file to a new PDF in the task workspace. Preserve the source. Quote each input and output path. - -```text -"" "" convert --input --output -``` - -Check the command result, nonempty output, and `%PDF-` file signature, then report that extent of validation. Unless the user requests deeper checks, do not search for PDF readers, generate images, or install tools to validate a conversion. Spreadsheet PDF pages follow workbook print settings; change those settings only when requested. Conversion may differ from the native application's pagination or fonts. - -If export fails, preserve the usable source and report the export limitation. Do not repeatedly convert an unchanged source or use conversion to investigate a failed preview. For PDF export alone, stop after these checks. Only a request for images or visual inspection calls for `office-render`; load it before that separate operation. Deliver the requested PDF through `present` when available, keeping the file in place. diff --git a/packages/skill/skill-office/assets/office-pptx/SKILL.md b/packages/skill/skill-office/assets/office-pptx/SKILL.md index c34c4212be..8c03a6844e 100644 --- a/packages/skill/skill-office/assets/office-pptx/SKILL.md +++ b/packages/skill/skill-office/assets/office-pptx/SKILL.md @@ -5,7 +5,7 @@ description: Create, read, edit, and validate PowerPoint presentations (.pptx). # PowerPoint presentations -Follow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and use its Python executable and bundled presentation libraries. Do not install npm or pip packages or locate a system interpreter for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation. +Use `python-pptx` unless an explicit user or applicable AGENTS.md requirement names another library. Run Python as described in the Python environment section below. Do not install npm packages for the default workflow. Keep scripts and output files in the task workspace. The runtime and skill directory contain shared read-only resources. Match the requested slide language and the supplied presentation's design when editing it. @@ -13,6 +13,10 @@ Name Python scripts for their task, such as `edit_pptx.py`; avoid module names s ## Create and edit +If `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step. + +python-pptx cannot read legacy `.ppt` files. Use `office_convert` with `format: "pptx"`, copy the result into the workspace, then edit the copy. + Use `python-pptx` to inspect or modify an existing presentation. Inspect slide layouts, text runs, images, tables, and charts before editing. Change only the requested content, preserve mixed text formatting, and save to a new file unless the user requests an in-place edit. Rebuilding slides can discard unsupported animation, SmartArt, or other extension content. For a new deck, use `python-pptx` with editable text, tables, and charts. Use local image assets rather than network-dependent image URLs. Set slide dimensions, text sizes, and chart data explicitly. @@ -53,12 +57,14 @@ for shape in presentation.slides[0].shapes: presentation.save("report-edited.pptx") ``` -Check that text and images fit the slide dimensions, titles form a useful sequence, and chart labels agree with source values. Paragraph alignment does not move a text box: when a title must be centered on the slide, center both its paragraph and the text box against `presentation.slide_width`, then reopen to verify both. A native chart's embedded workbook is part of the deliverable and must contain the intended data. Library support for writing PPTX is not a rendering engine or a guarantee that every PowerPoint feature survives editing. +Check that text and images fit the slide dimensions, titles form a useful sequence, and chart labels agree with source values. Paragraph alignment does not move a text box: when a title must be centered on the slide, center both its paragraph and the text box against `presentation.slide_width`, then reopen to verify both. A native chart's embedded workbook is part of the deliverable and must contain the intended data. ## Check and deliver -Reopen the saved presentation with python-pptx to verify the requested slide count, content, formatting, chart data, and notes, including unchanged content that must be preserved. These checks do not establish text fit, readable contrast, or rendering fidelity. +Reopen the saved presentation with python-pptx to verify the requested slide count, content, formatting, chart data, and notes, including unchanged content that must be preserved. python-pptx reads text and properties but cannot measure layout: slide text sits in fixed-size boxes, so wrapping, overflow, overlap, and contrast appear only in a rendering. -Deliver after the requested edits are verified. Creating a file or changing content with incidental basic formatting does not require additional operations. Load `office-render` for requested images or visual inspection and for appearance-focused styling, spacing, sizing, or alignment changes unless the user explicitly skips rendering. For PDF output load `office-export-pdf`. +Call `office_render` once on the new or changed slides after any edit to what a slide shows, including its text, images, charts, sizes, positions, and styling; a request to skip rendering takes precedence. python-pptx checks alone cover speaker notes and document properties. Deliver after these checks. For PDF output, call `office_convert` with `format: "pdf"`, then copy the returned temporary file to the destination. + +A fully transparent or uniformly blank image of populated content means the renderer failed, which ends visual checks for the task and leaves the source as it is. When content and structure checks pass, state the visual status only as "Content and structure checks passed; visual inspection was not completed." or "内容与结构检查通过;视觉检查未完成。". Call `present({"files":[{"path":"report-edited.pptx"}]})` with the actual final PPTX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and leave intermediate images and reports out of delivery unless requested. If `present` is unavailable, use the session's supported file delivery method. diff --git a/packages/skill/skill-office/assets/office-recalculate/SKILL.md b/packages/skill/skill-office/assets/office-recalculate/SKILL.md deleted file mode 100644 index b5c842a369..0000000000 --- a/packages/skill/skill-office/assets/office-recalculate/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: office-recalculate -description: Refresh spreadsheet formula caches and verify calculated values with LibreOffice Kit. ---- - -# Recalculate spreadsheet formulas - -Use the installed LibreOffice Kit CLI through the shell with the absolute Node and CLI paths supplied below. In Bash, invoke `"" ""`; in PowerShell, invoke `& "" ""`. Prefer bundled LibreOffice unless the user requests another installation. If the Office sidecar is missing, follow [sidecar setup](../office/references/sidecar.md): ask whether the user wants to download it and install only after they agree. Do not invoke system soffice, use npx, or install a replacement to work around a failed recalculation with an installed sidecar. - -Keep the original workbook and write a new file in the task workspace. Quote each input and output path. - -```text -"" "" recalculate --input --output -``` - -Verify the saved result with openpyxl, using `load_workspace_dependencies` for the Python path unless a project environment is required. Open it with both `data_only=False` and `data_only=True`. Check that formulas survive and cached results match the requested logic, expected totals, and cell types. Check required unchanged sheets, styles, and data. Recalculation does not validate business logic or guarantee Excel-specific feature compatibility. - -If no supported recalculation engine is available or the command fails, preserve the workbook and explain that calculated values could not be refreshed. Do not replace requested formulas with constants or present stale/absent caches as fresh results. Deliver the verified output through `present` when available, keeping the file in place. diff --git a/packages/skill/skill-office/assets/office-render/SKILL.md b/packages/skill/skill-office/assets/office-render/SKILL.md deleted file mode 100644 index df14704043..0000000000 --- a/packages/skill/skill-office/assets/office-render/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: office-render -description: Render requested document images or inspect visual layout with LibreOffice Kit. ---- - -# Document images and visual inspection - -Use the installed LibreOffice Kit CLI through the shell with the absolute Node and CLI paths supplied below. In Bash, invoke `"" ""`; in PowerShell, invoke `& "" ""`. Prefer bundled LibreOffice unless the user requests another installation. If the Office sidecar is missing, follow [sidecar setup](../office/references/sidecar.md): ask whether the user wants to download it and install only after they agree. Do not invoke system soffice, use npx, install a replacement renderer, or build an alternative rendering pipeline to work around a failed render with an installed sidecar. - -Before generating images only for inspection, establish that the current model accepts images. Otherwise finish the applicable content and structure checks and report the requested visual check as unavailable. Do not generate unreadable previews, retry image reads, or choose a second model. Requested image exports can still be delivered. - -## Render the saved deliverable - -Keep the saved deliverable in the task workspace. For requested image exports, use the requested output directory and retain the images. For inspection-only previews, create a private, uniquely named temporary parent inside that workspace and pass a child path that does not yet exist as `--output-dir`; the CLI creates that child. On POSIX, use `preview_root=$(mktemp -d "./.dsh-office.XXXXXXXX")` (owner-only permissions) and `--output-dir "$preview_root/render"`. On PowerShell, create a parent named `.dsh-office-` plus `[guid]::NewGuid().ToString('N')` under the task workspace and pass an uncreated `render` child; it inherits the workspace ACL. Quote each input and output path. Retain the parent until image inspection finishes, then remove only that parent, including after a failed render. Never use a fixed directory name or remove the workspace itself. Report inspection results in text; the reply must not link or embed removed previews. - -```text -"" "" render --input --output-dir --pages "1,3,4" -"" "" render --input --output-dir --sheet --range -``` - -For DOCX, PPTX, or PDF, batch requested pages/slides in one call. In PowerShell, quote comma-separated `--pages` values so they remain one argument. Inspect all pages only for a requested whole-document review. For worksheets, choose the exact affected range, including chart/drawing regions; worksheet numbers are not PDF page numbers. Inspect the final saved file, reuse existing images for an unchanged source, and rerender only after requested source changes or for previously unrendered regions. Change print areas or scaling only when the user requests print layout. - -Read the returned JSON manifest for image paths, page counts, fragment rectangles, and missing-font diagnostics. Use the session's image-reading tool to inspect the requested images. Check clipping, alignment, headings, table widths, contrast, chart labels, and number formats as relevant to the task. A successful command or image read alone does not establish a usable preview; guessed text geometry does not establish overflow. - -## Failed or blank previews - -For populated content, a fully transparent or uniformly blank image is a renderer failure. If a pixel check is needed, composite RGBA onto white first; raw RGB under transparent pixels can look nonblank. Check once and do not reread the same image. Stop visual checks for the task immediately after a blank result. This overrides the rerender-after-edit guidance above: do not render or read images again, even after editing or copying the source, or try another range, page, sheet, DPI, output format, diagnostic export, or print setting. Preserve the usable source and requested outputs and finish applicable content/data checks. - -If content and structure checks pass, name the saved file and verified edits as needed, then include exactly one visual-status sentence: "Content and structure checks passed; visual inspection was not completed." in English, or "内容与结构检查通过;视觉检查未完成。" in Chinese. Do not add a render or preview bullet, explain why inspection stopped, or describe the image or pixels. Report content or structure failures separately; never say all checks passed or claim visual verification. - -LibreOffice output can differ from Word, PowerPoint, or Excel. Images do not certify animation/media playback, refresh formula caches in the source workbook, or guarantee native application fidelity. Deliver requested files through `present` when available; keep the files in place. Do not deliver inspection images or reports unless requested. diff --git a/packages/skill/skill-office/assets/office-xlsx/SKILL.md b/packages/skill/skill-office/assets/office-xlsx/SKILL.md index ce56bae33b..d0d6f5abce 100644 --- a/packages/skill/skill-office/assets/office-xlsx/SKILL.md +++ b/packages/skill/skill-office/assets/office-xlsx/SKILL.md @@ -5,7 +5,7 @@ description: Read, create, edit, and validate Excel workbooks, data, and formula # Excel workbooks -Follow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and run its Python executable with bundled `openpyxl` and `pandas`. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation. +Use `openpyxl` and `pandas` unless an explicit user or applicable AGENTS.md requirement names another library. Run Python as described in the Python environment section below. Keep scripts, working files, and outputs in the task workspace. The runtime and skill directory are shared read-only resources. Save to a new workbook unless the user requests an in-place edit. @@ -13,6 +13,10 @@ Name Python scripts for their task, such as `edit_xlsx.py`; avoid module names s ## Read and modify +If `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step. + +openpyxl cannot read legacy `.xls` files. Use `office_convert` with `format: "xlsx"`, copy the result into the workspace, then edit the copy. + Use `openpyxl` for existing XLSX workbooks and targeted cell or style changes. Load with `data_only=False` when formulas must survive. Inspect sheet names, the affected cell types, formulas, styles, merged ranges, and referenced ranges before editing. Check the saved file by reopening it, including unchanged content that the request requires preserving. Use pandas for data analysis and transformations. A DataFrame is not the workbook: exporting it over an existing file can lose sheets, formulas, charts, and formatting. Write analysis results back to the intended ranges with openpyxl. `XlsxWriter` can create new workbooks, but cannot read or modify existing ones. @@ -37,16 +41,18 @@ workbook.save("report.xlsx") Preserve numbers, dates, booleans, and identifiers as the intended cell types; formatting is not a type conversion. For modifications, load the existing file with `openpyxl.load_workbook("input.xlsx", data_only=False)`, change the requested ranges, and save a separate result. Avoid a DataFrame round trip when workbook features must survive. -Writing a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate formulas; `data_only=True` returns cached values that may be absent or stale. When calculated results must be refreshed, load `office-recalculate`. Do not replace requested formulas with constants or report stale values as newly calculated results. +Writing a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate formulas; `data_only=True` returns cached values that may be absent or stale. When calculated results must be refreshed, call `office_recalculate` with `format: "xlsx"`, and copy the returned temporary workbook to a new file in the workspace. Open the copy with openpyxl using both `data_only=False` and `data_only=True` to check that formulas survive and cached values match the expected results. If recalculation fails, preserve the original workbook and report that calculated values were not refreshed. Do not replace requested formulas with constants or report stale or absent cached values as newly calculated results. -Do not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros and does not guarantee every advanced workbook feature survives. Preserve the original and verify such requirements explicitly. +Do not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros. Preserve the original and verify each workbook feature the task depends on. ## Check and deliver Reopen the saved workbook with openpyxl and verify the requested sheet names, values, types, formulas, styles, and totals against the source data, including unchanged content that must be preserved. These checks do not calculate formulas or judge workbook appearance. -Load `office-render` for requested images or visual inspection and whenever the user's primary task targets appearance, including cell fill colors, text alignment, spacing, or sizing. These appearance changes require rendering even without a separate visual-check request, unless the user explicitly skips it. New workbooks and data or formula edits do not require rendering solely because they include incidental basic formatting. In particular, bold headers and number formats accompanying data edits do not require loading `office-render` or running its commands. +Call `office_render` when the user asks for images or visual inspection, or when the task targets appearance, such as cell fill colors, text alignment, spacing, or sizing; a request to skip rendering takes precedence. Do not render new workbooks or data or formula edits merely to verify accompanying bold headers or number formats; check these with openpyxl alone. -Load `office-recalculate` when refreshed calculated values are needed and `office-export-pdf` for PDF output. Deliver after the requested data checks pass and the required operations finish. +Render worksheets by `sheet` and the affected `range`; worksheet numbers are not PDF page numbers. Earlier images of an unchanged source remain current. A fully transparent or uniformly blank image of populated content means the renderer failed, which ends visual checks for the task and leaves the source as it is. When content and structure checks pass, state the visual status only as "Content and structure checks passed; visual inspection was not completed." or "内容与结构检查通过;视觉检查未完成。". + +Recalculate when refreshed calculated values are needed. For PDF output, call `office_convert` with `format: "pdf"`, then copy the returned temporary file to the destination. Deliver after the requested data checks pass and the required operations finish. Call `present({"files":[{"path":"report.xlsx"}]})` with the actual final workbook path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and omit intermediate scripts and reports unless requested. If `present` is unavailable, use the session's supported file delivery method. diff --git a/packages/skill/skill-office/assets/office/SKILL.md b/packages/skill/skill-office/assets/office/SKILL.md index be9e149be1..2f2a2e1209 100644 --- a/packages/skill/skill-office/assets/office/SKILL.md +++ b/packages/skill/skill-office/assets/office/SKILL.md @@ -1,15 +1,16 @@ --- name: office -description: Create, edit, inspect, or convert Office documents, spreadsheets, presentations, and PDFs. Load to choose the relevant workflow. +description: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step. --- # Office task routing -Call the `skill` tool with the format skill name before creating, reading, or editing Office files. Standalone rendering, PDF export, and recalculation need only their operation skill. +Load `office-docx`, `office-pptx`, or `office-xlsx` to create, read, or edit; operations alone need no format skill. -- Create, read, or edit: `office-docx` (Word), `office-pptx` (PowerPoint), or `office-xlsx` (Excel). -- Images, visual inspection, or tasks primarily about layout, spacing, sizing, or alignment: `office-render`, even without an explicit visual-check request. -- Refresh spreadsheet formula results: `office-recalculate`. -- Export an Office file to PDF: `office-export-pdf`. +If `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step. -Honor requests to skip rendering. New files, content edits, formulas, and incidental formatting such as bold headers or number formats need no render unless visual inspection is requested. Do not reload skills. +- `office_render`: Office or PDF images, visual checks, and appearance or slide checks required by the format skill; skip it when asked. +- `office_recalculate`: formulas, with `format: "xlsx"` or `"ods"`. +- `office_convert` with `format`; Office inputs only, not PDFs. For PDF export alone, check the copy is nonempty and starts with `%PDF-`, then deliver it without rendering. + +Copy temporary outputs to the destination, keeping existing files unless asked to replace them. Reuse loaded skills. diff --git a/packages/skill/skill-office/assets/office/references/sidecar.md b/packages/skill/skill-office/assets/office/references/sidecar.md deleted file mode 100644 index 73df2f07b5..0000000000 --- a/packages/skill/skill-office/assets/office/references/sidecar.md +++ /dev/null @@ -1,17 +0,0 @@ -# Missing Office sidecar - -These instructions apply in Web, Desktop, and SDK sessions. First check whether an existing LibreOffice Kit installation can serve the task; a missing `dsoffice` command on PATH alone does not establish that the sidecar is absent. If the required sidecar is missing and the user has not already authorized its download, use the available ask-question tool to ask whether they want to download the Office conversion engine. If that tool is unavailable, ask in the conversation. Wait for explicit agreement before downloading or installing; if the user declines, continue work that does not need the engine and report the unavailable operation. - -After agreement, install `@deepseek-ai/libreoffice-kit` together with the matching engine package, using the engine version declared by the main package's optional dependencies. Use an available npm or pnpm in a task-local tools directory, with `--ignore-scripts`: the packages provide prebuilt binaries and do not need installation scripts. Keep the package manager's cache or store in that writable task directory too (for npm, pass `--cache /.npm-cache`). Select the package for the operating system and architecture of the Node.js process that will run the CLI, including when Node runs under emulation: - -| Runtime platform | Engine package | -| --- | --- | -| Windows x64 | `@deepseek-ai/libreoffice-kit-win32-x64` | -| Windows ARM64 | `@deepseek-ai/libreoffice-kit-win32-arm64` | -| macOS Apple Silicon | `@deepseek-ai/libreoffice-kit-darwin-arm64` | -| macOS Intel | `@deepseek-ai/libreoffice-kit-darwin-x64` | -| No matching native package, including Linux | `@deepseek-ai/libreoffice-kit-wasm` | - -Prefer the matching native package; otherwise use WASM when its package supports the current platform. Check package platform restrictions before installation rather than forcing an incompatible package. The published WASM package currently declares Linux support. - -Reuse the bundled Node.js and pnpm paths from `load_workspace_dependencies` when available, or the user's configured Node.js and package manager. Permission to download the Office sidecar does not include installing Python, Node.js, or a package manager. If those prerequisites are missing, explain what is needed. Run the new installation's `dsoffice` entry, or its `lib/cli.js` with the selected Node executable, by path; do not assume installation changed the session's PATH. Check `dsoffice capabilities` before continuing the requested operation. diff --git a/packages/skill/skill-office/package.json b/packages/skill/skill-office/package.json index 1b0b22a046..559cbf7f0d 100644 --- a/packages/skill/skill-office/package.json +++ b/packages/skill/skill-office/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-office", - "description": "Bundled Word, PowerPoint, and Excel workflows and LibreOffice Kit operations", - "version": "0.2.2-alpha.3", + "description": "Bundled Word, PowerPoint, and Excel workflows and Office operation guidance", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "locale/*.json", "lib/index.js", "assets", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -41,7 +44,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:~" }, "dependencies": { - "@deepseek-ai/libreoffice-kit": "^0.1.5", "@deepseek-ai/schemastery": "workspace:~", "yaml": "^2.4.2" } diff --git a/packages/skill/skill-office/src/index.ts b/packages/skill/skill-office/src/index.ts index b1e5a87179..f66f59b505 100644 --- a/packages/skill/skill-office/src/index.ts +++ b/packages/skill/skill-office/src/index.ts @@ -1,32 +1,45 @@ /** Bundled Office workflows and filesystem resources for document authoring and operations. */ -import { readFileSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { dirname, isAbsolute, join, sep } from 'node:path' -import { isSea } from 'node:sea' +import { isAbsolute, join, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { BUNDLED_SKILL_RANK, type SkillCandidate, type SkillProvider } from '@deepseek-ai/dsh-skill' +import { BUNDLED_SKILL_RANK, type SkillCandidate } from '@deepseek-ai/dsh-skill' import { parse as parseYaml } from 'yaml' -const SKILL_NAMES = ['office', 'office-docx', 'office-pptx', 'office-xlsx', 'office-render', 'office-recalculate', 'office-export-pdf'] as const +const SKILL_NAMES = ['office', 'office-docx', 'office-pptx', 'office-xlsx'] as const +const PYTHON_SKILLS: readonly string[] = ['office-docx', 'office-pptx', 'office-xlsx'] -/** Office skill resource location. */ +/** + * Python source for Office scripts: `'workspace-dependencies'` directs the model to the bundled interpreter and libraries + * returned by `load_workspace_dependencies`; `'user'` directs it to an existing project or user Python. + */ +export type PythonEnvironment = 'workspace-dependencies' | 'user' + +const PYTHON_ENVIRONMENT_SECTIONS: Readonly> = { + 'workspace-dependencies': 'Follow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise call `load_workspace_dependencies` and run scripts with its Python executable and bundled libraries. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.', + user: 'Follow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise use the project\'s or user\'s existing Python, such as `python3` or `python` on PATH. Before the first script, check that this Python can import the libraries the workflow needs. If Python or a library is missing, tell the user which packages to install; do not install packages into a global environment without the user\'s agreement.', +} + +/** Office skill resources and Python environment. */ export interface Config { /** Absolute assets directory containing skill folders; defaults to packaged assets. */ assetRoot?: string - /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ - node?: string - /** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */ - cli?: string | false + /** Python environment named in the Python authoring and verification skills. @default 'workspace-dependencies' */ + pythonEnvironment?: PythonEnvironment +} + +/** Configuration after schema defaults apply. */ +export interface ResolvedConfig extends Config { + /** Python environment named in the Python authoring and verification skills. */ + pythonEnvironment: PythonEnvironment } /** Validated resource configuration. */ -export const Config: z = z.object({ +export const Config: z = z.object({ assetRoot: z.string().min(1), - node: z.string().min(1), - cli: z.union([z.string().min(1), z.const(false)]), + pythonEnvironment: z.union([z.const('workspace-dependencies'), z.const('user')]).default('workspace-dependencies'), }) /** Cordis plugin identity. */ @@ -44,52 +57,85 @@ function parseSkill(raw: string, path: string): { description: string; content: return { description, content: raw.slice(frontmatter[0].length).trim() } } -function officeRuntime(config: Config): string { - if (config.cli === false) return '\n\nLibreOffice Kit is disabled in this deployment.' - if (config.node === undefined && (isSea() || process.versions.electron !== undefined)) { - throw new Error('skill-office: packaged applications must supply a standalone Node executable') - } - const node = config.node ?? process.execPath - let cli = config.cli - if (cli === undefined) { - const manifestPath = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json')) - cli = join(dirname(manifestPath), 'lib', 'cli.js') - } - for (const path of [node, cli]) { - if (!isAbsolute(path) || !statSync(path).isFile()) throw new Error(`skill-office: expected an absolute executable file: ${path}`) - } - return `\n\n## Installed LibreOffice Kit\n\nUse these absolute paths for every LibreOffice Kit command. Pass the CLI entry as the first argument to Node.\n\n${JSON.stringify({ libreofficeKit: { node, cli } }, undefined, 2)}` +/** Outcome of reading one skill file: parsed metadata, or a failure that may clear on a later read. */ +type SkillRead = + | { readonly kind: 'loaded'; readonly description: string; readonly content: string } + | { readonly kind: 'failed'; readonly error: unknown; readonly persistent: boolean } + +/** Missing paths persist until the file appears; other read errors, such as locks, may clear. */ +function persistentReadFailure(error: unknown): boolean { + const code = error instanceof Error && 'code' in error ? error.code : undefined + return code === 'ENOENT' || code === 'ENOTDIR' } /** - * Register Office skills with resources readable by the script interpreter. + * Register Office skills whose `SKILL.md` files the Host reads from `assetRoot`. + * Skill files are read during catalog discovery and loading, and each distinct failure is reported once. A missing + * or invalid skill is omitted from a complete catalog, and its failed load returns `undefined` and invalidates the + * catalog. Any other read error leaves discovery incomplete, so the registry retries it, and rejects a load. * @param ctx - Context carrying the skill registry. - * @param config - Optional external assets directory for packaged applications. + * @param config - Resolved configuration; `assetRoot` falls back to the packaged assets. + * @throws When `assetRoot` is not absolute. */ -export function apply(ctx: Context, config: Config = {}): void { +export function apply(ctx: Context, config: ResolvedConfig): void { const assetRoot = config.assetRoot ?? fileURLToPath(new URL('../assets/', import.meta.url)) if (!isAbsolute(assetRoot)) throw new Error('skill-office: assetRoot must be an absolute directory') - const candidates: SkillCandidate[] = SKILL_NAMES.map((skillName) => { - const directory = join(assetRoot, skillName) - const path = join(directory, 'SKILL.md') - const { description } = parseSkill(readFileSync(path, 'utf8'), path) - return { - name: skillName, description, - invocation: { modelInvocable: true, userInvocable: true }, - provider: 'dsh-office', source: 'bundled', rank: BUNDLED_SKILL_RANK, - resourceBase: { kind: 'directory', path: directory.split(sep).join('/') }, locator: path, + const reportedFailures = new Map() + async function readSkill(skillName: string, signal: AbortSignal | undefined): Promise { + const path = join(assetRoot, skillName, 'SKILL.md') + let raw: string + try { + raw = await readFile(path, { encoding: 'utf8', signal }) + } catch (error) { + if (signal?.aborted === true) throw error + return failed(skillName, error, persistentReadFailure(error)) } - }) - const provider: SkillProvider = { + let skill: { description: string; content: string } + try { + skill = parseSkill(raw, path) + } catch (error) { + // Invalid metadata, including YAML errors that carry their own codes, persists until the file changes. + return failed(skillName, error, true) + } + reportedFailures.delete(skillName) + return { kind: 'loaded', ...skill } + } + function failed(skillName: string, error: unknown, persistent: boolean): SkillRead { + const failure = String(error) + if (reportedFailures.get(skillName) !== failure) { + reportedFailures.set(skillName, failure) + ctx.logger.warn(`skill-office: skill "${skillName}" is unavailable: ${failure}`) + } + return { kind: 'failed', error, persistent } + } + ctx.skills.registerProvider(control => ({ name: 'dsh-office', - list: () => Promise.resolve(candidates), + async list(options) { + const reads = await Promise.all(SKILL_NAMES.map(async skillName => ({ skillName, read: await readSkill(skillName, options.signal) }))) + const candidates: SkillCandidate[] = [] + for (const { skillName, read } of reads) { + if (read.kind === 'failed') continue + const directory = join(assetRoot, skillName) + candidates.push({ + name: skillName, description: read.description, + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'dsh-office', source: 'bundled', rank: BUNDLED_SKILL_RANK, + resourceBase: { kind: 'directory', path: directory.split(sep).join('/') }, locator: join(directory, 'SKILL.md'), + }) + } + return { candidates, complete: reads.every(({ read }) => read.kind === 'loaded' || read.persistent) } + }, async get(candidate, options) { - const { rank: _rank, locator, ...summary } = candidate - const raw = await readFile(locator as string, { encoding: 'utf8', signal: options.signal }) - const content = parseSkill(raw, locator as string).content - + (['office-render', 'office-recalculate', 'office-export-pdf'].includes(candidate.name) ? officeRuntime(config) : '') + const { rank: _rank, locator: _locator, ...summary } = candidate + const read = await readSkill(candidate.name, options.signal) + if (read.kind === 'failed') { + if (!read.persistent) throw read.error + control.invalidate() + return undefined + } + const content = read.content + + (PYTHON_SKILLS.includes(candidate.name) ? `\n\n## Python environment\n\n${PYTHON_ENVIRONMENT_SECTIONS[config.pythonEnvironment]}` : '') return { ...summary, content } }, - } - ctx.skills.registerProvider(() => provider) + })) } diff --git a/packages/skill/skill-office/tests/fixtures/real-office.ts b/packages/skill/skill-office/tests/fixtures/real-office.ts index 5ffa3a2de4..4b24ec867e 100644 --- a/packages/skill/skill-office/tests/fixtures/real-office.ts +++ b/packages/skill/skill-office/tests/fixtures/real-office.ts @@ -21,11 +21,44 @@ export function officeModelPatches() { ] } +/** @returns Patches mounting the Office kit and tools, which the headless profile does not compose. */ +export function officeToolPatches() { + return [{ insert: [ + { id: 'office-kit', name: '@deepseek-ai/dsh-office-kit' }, + { id: 'tool-office', name: '@deepseek-ai/dsh-tool-office' }, + ] }] +} + +/** One Office call from a native tool call or a PTC dispatch. */ +export interface OfficeCall { + readonly callId: string + readonly name: 'office_render' | 'office_convert' | 'office_recalculate' + readonly args: { readonly input?: string; readonly format?: string; readonly pages?: number[] } +} + /** - * Assert that a non-rendering task has received no rendering commands or detailed guidance. + * Collect the Office calls of a recorded task by tool name. + * @param events - Recorded task events. + * @returns Native and PTC Office calls in log order. + */ +export function officeCalls(events: readonly SessionEvent[]): OfficeCall[] { + return events.flatMap((event): OfficeCall[] => { + if (event.type !== 'tool/call' && event.type !== 'tool/ptc-dispatch') return [] + const { name } = event.data + if (name !== 'office_render' && name !== 'office_convert' && name !== 'office_recalculate') return [] + return event.type === 'tool/call' + ? [{ callId: event.data.callId, name, args: JSON.parse(event.data.arguments) as OfficeCall['args'] }] + : [{ callId: event.data.subCallId, name, args: event.data.arguments as OfficeCall['args'] }] + }) +} + +/** + * Reject recorded Office render calls and the "Render the saved deliverable" instruction. * @param events - Recorded task events, including nested skill and shell results. */ -export function expectNoRenderContext(events: SessionEvent[]): void { +export function expectNoRenderContext(events: readonly SessionEvent[]): void { + const calls = officeCalls(events) + expect(calls.filter(call => call.name === 'office_render'), JSON.stringify(calls)).toEqual([]) const messages = Session.create(SessionId('office-context'), events).deriveMessages() - expect(JSON.stringify(messages)).not.toMatch(/imageRendering|maxPixels|fragment rectangles|render --input|--output-dir|uniformly blank/) + expect(JSON.stringify(messages)).not.toMatch(/Render the saved deliverable/) } diff --git a/packages/skill/skill-office/tests/fixtures/render-cli.ts b/packages/skill/skill-office/tests/fixtures/render-cli.ts deleted file mode 100644 index aca544d975..0000000000 --- a/packages/skill/skill-office/tests/fixtures/render-cli.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** Record real CLI operations and the appearance of rendered pixels. */ -import { mkdir, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -/** - * Install a CLI wrapper in a caller-owned test directory. - * @param cwd - Private fixture directory owned and removed by the calling test. - * @param python - Bundled Python executable used to inspect preview pixels. - * @param blank - Replace successful render images with fully transparent pixels. - * @param calls - Operation-log path in an existing writable directory; defaults beside the wrapper. - * @returns Path to the wrapper and its operation log. - */ -export async function renderCliFixture( - cwd: string, python: string, blank: boolean, calls = join(cwd, '.fixture', 'calls.jsonl'), -): Promise<{ cli: string; calls: string }> { - const cli = join(cwd, '.fixture', 'cli.js') - const kit = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/cli')) - const inspectPixels = [ - 'from pathlib import Path', - 'from PIL import Image', - 'import sys', - "paths = list(Path(sys.argv[1]).rglob('*.png'))", - 'assert paths', - 'all_blank = True', - 'for path in paths:', - ' with Image.open(path) as image:', - ' size = image.size', - " if sys.argv[2] == 'true':", - " Image.new('RGBA', size, (0, 0, 0, 0)).save(path)", - ' with Image.open(path) as image:', - " rgba = image.convert('RGBA')", - " composite = Image.new('RGBA', rgba.size, 'white')", - ' composite.alpha_composite(rgba)', - " all_blank &= all(low == high for low, high in composite.convert('RGB').getextrema())", - 'print(str(all_blank).lower())', - ].join('\n') - await mkdir(dirname(cli)) - await writeFile(cli, ` -const { spawnSync } = require('node:child_process'); -const { appendFileSync } = require('node:fs'); -const args = process.argv.slice(2); -const result = spawnSync(process.execPath, [${JSON.stringify(kit)}, ...args], { encoding: 'utf8' }); -let pixelsBlank = null; -if (result.status === 0 && args[0] === 'render') { - const pixels = spawnSync(${JSON.stringify(python)}, ['-c', ${JSON.stringify(inspectPixels)}, args[args.indexOf('--output-dir') + 1], String(${blank})], { encoding: 'utf8' }); - if (pixels.status !== 0) throw new Error(pixels.stderr); - pixelsBlank = pixels.stdout.trim() === 'true'; -} -if (result.status === 0 && args[0] === 'capabilities') { - const capabilities = JSON.parse(result.stdout); - capabilities.runtime.cliPath = __filename; - result.stdout = JSON.stringify(capabilities); -} -appendFileSync(${JSON.stringify(calls)}, JSON.stringify({ args, status: result.status, pixelsBlank }) + '\\n'); -process.stdout.write(result.stdout); -process.stderr.write(result.stderr); -process.exitCode = result.status ?? 1; -`) - return { cli, calls } -} diff --git a/packages/skill/skill-office/tests/fixtures/visual-intent.ts b/packages/skill/skill-office/tests/fixtures/visual-intent.ts index e3525a766f..8754fd5013 100644 --- a/packages/skill/skill-office/tests/fixtures/visual-intent.ts +++ b/packages/skill/skill-office/tests/fixtures/visual-intent.ts @@ -1,14 +1,13 @@ /** Register real Office authoring visual-intent e2e cases for the selected document formats. */ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { expect, it } from 'vitest' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { officeModelPatches } from './real-office.ts' -import { renderCliFixture } from './render-cli.ts' +import { officeCalls, officeModelPatches, officeToolPatches } from './real-office.ts' const repo = fileURLToPath(new URL('../../../../../', import.meta.url)) const assetRoot = fileURLToPath(new URL('../../assets/', import.meta.url)) @@ -21,6 +20,9 @@ const formats = [ { contentTask: 'change the title text on slide 1 to Quarterly results without changing its formatting', verifyContent: "from pptx import Presentation\np=Presentation('result.pptx'); assert len(p.slides) == 1; assert p.slides[0].shapes[0].text == 'Quarterly results'", + contentRenders: true, + notesTask: 'set the speaker notes of slide 1 to Revenue grew 50%. without changing the slide', + verifyNotes: "from pptx import Presentation\np=Presentation('result.pptx'); s=p.slides[0]; assert s.has_notes_slide and s.notes_slide.notes_text_frame.text.strip() == 'Revenue grew 50%.'; assert s.shapes[0].text == 'Quarterly revenue'", format: 'pptx', task: 'center the title on slide 1 and set its font color to #1F4E79', seed: "from pptx import Presentation\nfrom pptx.util import Inches\np=Presentation(); s=p.slides.add_slide(p.slide_layouts[6]); s.shapes.add_textbox(Inches(1),Inches(1),Inches(6),Inches(1)).text='Quarterly revenue'; p.save('input.pptx')", verify: "from pptx import Presentation\nfrom pptx.enum.text import PP_ALIGN\np=Presentation('result.pptx'); title=p.slides[0].shapes[0]; r=title.text_frame.paragraphs[0]; assert r.alignment == PP_ALIGN.CENTER; assert abs(2*title.left+title.width-p.slide_width) <= 1; assert str(r.runs[0].font.color.rgb).upper() == '1F4E79'", @@ -28,6 +30,7 @@ const formats = [ { contentTask: 'change the title paragraph text to Quarterly results without changing its formatting', verifyContent: "from docx import Document\nd=Document('result.docx'); assert [p.text for p in d.paragraphs] == ['Quarterly results', 'Q1: 12; Q2: 18']", + contentRenders: false, format: 'docx', task: 'center the title paragraph and set its font color to #1F4E79', seed: "from docx import Document\nd=Document(); d.add_paragraph('Quarterly revenue'); d.add_paragraph('Q1: 12; Q2: 18'); d.save('input.docx')", verify: "from docx import Document\nfrom docx.enum.text import WD_ALIGN_PARAGRAPH\nd=Document('result.docx'); p=d.paragraphs[0]; assert p.alignment == WD_ALIGN_PARAGRAPH.CENTER; assert p.runs[0].font.color.rgb == (31,78,121)", @@ -35,53 +38,48 @@ const formats = [ { contentTask: 'change Revenue!B2 to 20 without changing the cell formatting or other data', verifyContent: "from openpyxl import load_workbook\nw=load_workbook('result.xlsx'); s=w['Revenue']; assert list(s.values) == [('Quarter','Revenue'),('Q1',20),('Q2',18)]", + contentRenders: false, format: 'xlsx', task: 'center the text in Revenue!A1:B1 and set the cells\' fill color to #1F4E79', seed: "from openpyxl import Workbook\nw=Workbook(); s=w.active; s.title='Revenue'; s.append(['Quarter','Revenue']); s.append(['Q1',12]); s.append(['Q2',18]); w.save('input.xlsx')", verify: "from openpyxl import load_workbook\nw=load_workbook('result.xlsx'); s=w['Revenue']; assert all(s[c].alignment.horizontal == 'center' and s[c].fill.fgColor.rgb.endswith('1F4E79') for c in ('A1','B1')); assert s['B2'].value == 12 and s['B3'].value == 18", }, ] as const -const intents = [ - { intent: 'explicit', suffix: 'Render the finished file and visually inspect the changed page or sheet.' }, - { intent: 'excluded', suffix: 'Do not render or visually inspect the finished file.' }, - { intent: 'implicit', suffix: '' }, -] as const - -function within(parent: string, path: string): boolean { - const part = relative(parent, path) - return part === '' || part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part) -} +const explicitIntent = { intent: 'explicit', suffix: 'Render the finished file and visually inspect the changed page or sheet.' } as const +const excludedIntent = { intent: 'excluded', suffix: 'Do not render or visually inspect the finished file.' } as const +const implicitIntent = { intent: 'implicit', suffix: '' } as const +const intents = [explicitIntent, excludedIntent, implicitIntent] /** - * Register the visual-intent e2e cases for the selected document formats. + * Register the visual-intent e2e cases for the selected document formats. Without an explicit request, appearance + * edits render once, content edits render once only where the format's library cannot check their layout (PPTX + * fixed-size text boxes), and PPTX speaker-notes edits never render. * @param selected - Document formats whose cases this spec file registers. - * @param options - `blank` also registers the pptx case whose preview fixture produces blank pixels. */ -export function registerVisualIntentCases(selected: readonly DocumentFormat[], options: { blank?: boolean } = {}): void { - const cases = [ - ...formats.filter(format => selected.includes(format.format)).flatMap(format => intents.flatMap(intent => [ - { ...format, ...intent, kind: 'format' }, - { ...format, ...intent, kind: 'content', task: format.contentTask, verify: format.verifyContent }, - ])), - ...options.blank === true ? [{ ...formats[0], kind: 'format', intent: 'blank', suffix: intents[0].suffix }] : [], - ] +export function registerVisualIntentCases(selected: readonly DocumentFormat[]): void { + const cases = formats.filter(format => selected.includes(format.format)).flatMap(format => [ + ...intents.flatMap(intent => [ + { ...format, ...intent, kind: 'format', rendersUnrequested: true }, + { ...format, ...intent, kind: 'content', task: format.contentTask, verify: format.verifyContent, rendersUnrequested: format.contentRenders }, + ]), + ...'notesTask' in format + ? [{ ...format, ...implicitIntent, kind: 'notes', task: format.notesTask, verify: format.verifyNotes, rendersUnrequested: false }] + : [], + ]) - it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(cases)('$format $kind $intent visual intent', async ({ format, kind, task, seed, verify, intent, suffix }) => { + it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(cases)('$format $kind $intent visual intent', async ({ format, kind, task, seed, verify, intent, suffix, rendersUnrequested }) => { const controlRoot = await mkdtemp(join(tmpdir(), 'dsh-office-control-test-')) try { const previewRoot = join(controlRoot, 'tmp') await mkdir(previewRoot) const configPath = join(controlRoot, 'office.patch.json') const sessionRoot = join(controlRoot, 'sessions') - // The model can remove its entire preview directory during cleanup. - const callLogPath = join(controlRoot, 'cli-calls.jsonl') await runLoaderSmoke({ label: `Office ${format} ${kind} ${intent} visual intent`, tempDirPrefix: `dsh-office-${format}-${kind}-${intent}-`, binScript: join(repo, 'apps/cli/src/bin.ts'), sourceImport: 'tsx/esm', tsconfigPath: join(repo, 'tsconfig.base.json'), configPath, processTimeoutMs: 110_000, binArgs: ['--profile', 'headless', '--patch', configPath, `In input.${format}, ${task}; save result.${format}. ${suffix} Respond briefly in English.`], - // The CLI fixture and its log live outside the workspace; bwrap's private /tmp hides both. env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: join(controlRoot, 'home'), DSH_AGENTS_HOME: join(controlRoot, 'agents'), DSH_TELEMETRY_DISABLED: '1', @@ -89,10 +87,10 @@ export function registerVisualIntentCases(selected: readonly DocumentFormat[], o HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY }, async prepare(cwd) { await execa(python!, ['-c', seed], { cwd }) - const fixture = await renderCliFixture(controlRoot, python!, intent === 'blank', callLogPath) const testAssetRoot = process.env.DSH_OFFICE_TEST_ASSET_ROOT ?? assetRoot await writeFile(configPath, JSON.stringify([ - { insert: [{ id: 'skill-office', name: '@deepseek-ai/dsh-skill-office', config: { assetRoot: testAssetRoot, node: process.execPath, cli: fixture.cli } }] }, + { insert: [{ id: 'skill-office', name: '@deepseek-ai/dsh-skill-office', config: { assetRoot: testAssetRoot } }] }, + ...officeToolPatches(), { insert: [{ id: 'workspace-dependencies', name: '@deepseek-ai/dsh-tool-workspace-dependencies', config: { source: runtime! } }] }, ...officeModelPatches(), { id: 'session-persistence-jsonl', config: { root: sessionRoot, compression: 'none' } }, @@ -111,43 +109,29 @@ export function registerVisualIntentCases(selected: readonly DocumentFormat[], o const events = parseSessionLog(bytes.toString('utf8')) const calls = events.filter(event => event.type === 'tool/call').map(event => event.data) const loaded = calls.filter(call => call.name === 'skill').map(call => (JSON.parse(call.arguments) as { name: string }).name) - const imageReads = calls.filter(call => call.name === 'read_image') + const renderCalls = officeCalls(events).filter(call => call.name === 'office_render') + const rendered = events.filter(event => event.type === 'tool/result' && renderCalls.some(call => call.callId === event.data.message.toolCallId) + && !event.data.message.isError && event.data.message.content.some(block => block.type === 'image')).length const shellCommands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh') .map(call => (JSON.parse(call.arguments) as { command: string }).command) const answer = events.findLast(event => event.type === 'assistant/message') const text = answer?.type === 'assistant/message' ? answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') : undefined - const callLog = await readFile(callLogPath, 'utf8').catch((error: unknown) => { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return '' - throw error - }) - const renderCalls = callLog.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { args: string[]; status: number | null; pixelsBlank: boolean | null }) - .filter(call => call.args[0] === 'render') const diagnostic = JSON.stringify({ scenario: `${format} ${kind} ${intent}`, loadedSkills: loaded, shellCommands: shellCommands.map(command => command.slice(0, 600)), - renderCalls, imageReads: imageReads.length, + renderCalls, renderedImages: rendered, answer: text?.slice(0, 800) ?? '(missing)', }, null, 2) if (text === undefined) throw new Error(`Agent did not finish with an answer: ${diagnostic}`) const checked = await execa(python!, ['-c', verify], { cwd, reject: false }) expect(checked.exitCode, `${checked.stderr}\n${diagnostic}`).toBe(0) expect(loaded, diagnostic).toContain(`office-${format}`) - if (intent === 'excluded' || kind === 'content' && intent !== 'explicit') { - expect(loaded, diagnostic).not.toContain('office-render') + if (intent === 'excluded' || intent === 'implicit' && !rendersUnrequested) { expect(renderCalls, diagnostic).toEqual([]) - expect(imageReads, diagnostic).toEqual([]) } else { - expect(loaded, diagnostic).toContain('office-render') expect(renderCalls, diagnostic).toHaveLength(1) - expect(imageReads.length, diagnostic).toBeLessThanOrEqual(1) - for (const call of renderCalls) { - const output = call.args[call.args.indexOf('--output-dir') + 1] - expect(output, diagnostic).toBeDefined() - expect(within(cwd, resolve(cwd, output!)), diagnostic).toBe(true) - expect(call.status, diagnostic).toBe(0) - } - if (renderCalls[0]?.pixelsBlank || imageReads.length === 0) { + if (rendered === 0) { expect(text.match(/Content and structure checks passed; visual inspection was not completed\./gu), diagnostic).toHaveLength(1) expect(text, diagnostic).not.toMatch(/all checks passed|visually (?:verified|validated|confirmed)/iu) } diff --git a/packages/skill/skill-office/tests/progressive-loading.spec.ts b/packages/skill/skill-office/tests/progressive-loading.spec.ts index 0a7b330ec0..eb3ab299f3 100644 --- a/packages/skill/skill-office/tests/progressive-loading.spec.ts +++ b/packages/skill/skill-office/tests/progressive-loading.spec.ts @@ -1,4 +1,4 @@ -/** Model request contents for independent Office authoring and operation skills. */ +/** Model request contents for the Office router and independent authoring skills. */ import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { afterEach, expect, it } from 'vitest' @@ -16,6 +16,7 @@ import SkillRegistry from '@deepseek-ai/dsh-skill' import * as ToolSkill from '../../tool-skill/src/index.ts' import * as SkillOffice from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { expectNoRenderContext } from './fixtures/real-office.ts' const contexts: Context[] = [] afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() }) @@ -66,7 +67,7 @@ it.each(['native', 'ptc'] as const)('loads the router and authoring body in %s; await send(agent, 'Edit the Word document text.') expect(adapter.requests).toHaveLength(3) const requests = adapter.requests.map(request => JSON.stringify(request)) - expect(requests[0]).toContain('Create, edit, inspect, or convert Office') + expect(requests[0]).toContain('Create or edit Office files; inspect Office files and PDFs.') expect(requests[0]).toContain('office-docx') expect(requests[1]).toContain('Office task routing') expect(requests[1]).not.toContain('python-docx') @@ -74,12 +75,12 @@ it.each(['native', 'ptc'] as const)('loads the router and authoring body in %s; if (mode === 'ptc') { expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'skill-invocation')).toEqual([]) } - expect(requests[2]).toContain('office-render') + expect(requests[2]).toContain('`office_render`') expect(requests[2]).not.toContain('dsoffice') expect(requests[0]).not.toContain('dsoffice') expect(requests[1]).not.toContain('dsoffice') for (const request of requests) { - expect(request).not.toMatch(/uniformly blank|fragment rectangles/) + expect(request).not.toMatch(/fragment rectangles/) } const seed = agent.session.snapshotEvents() const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('resumed-office'), agentOptions: { provider: 'mock', model: 'model' }, seed }) @@ -89,23 +90,21 @@ it.each(['native', 'ptc'] as const)('loads the router and authoring body in %s; for (const request of adapter.requests.slice(3)) { const text = JSON.stringify(request) expect(text).toContain('python-docx') - expect(text).toContain('office-render') + expect(text).toContain('`office_render`') expect(text).not.toContain('dsoffice') - expect(text).not.toMatch(/uniformly blank|fragment rectangles/) + expect(text).not.toMatch(/fragment rectangles/) } expect(agent.session.snapshotEvents()).toEqual(seed) }) -it.each(['office-render', 'office-recalculate', 'office-export-pdf'])('loads %s directly without unrelated operation or authoring bodies', async (name) => { - const { agent, adapter } = await setup('native', [name]) +it('routes standalone operations to dedicated Office tools without authoring bodies', async () => { + const { agent, adapter } = await setup('native', ['office']) await send(agent) const loaded = JSON.stringify(adapter.requests.at(-1)) - expect(loaded).toContain('libreofficeKit') - expect(loaded).not.toContain('Office task routing') - expect(loaded).not.toContain('# Word documents') - if (name !== 'office-render') expect(loaded).not.toMatch(/render --input|uniformly blank|fragment rectangles|DPI/) - if (name !== 'office-recalculate') expect(loaded).not.toContain('recalculate --input') - if (name !== 'office-export-pdf') expect(loaded).not.toContain('convert --input') + expect(loaded).toContain('Office task routing') + for (const name of ['office_render', 'office_recalculate', 'office_convert']) expect(loaded).toContain(`\`${name}\``) + expect(loaded).not.toContain('operation:') + expect(loaded).not.toMatch(/# Word documents|# Excel workbooks|python-docx/) }) it('loads an explicitly invoked authoring skill without the router', async () => { @@ -116,3 +115,11 @@ it('loads an explicitly invoked authoring skill without the router', async () => expect(text).toContain('# Excel workbooks') expect(text).not.toMatch(/Office task routing|dsoffice/) }) + +it.each(['native', 'ptc'] as const)('accepts loaded format skills without a render call as a non-rendering context in %s', async (mode) => { + const { agent } = await setup(mode, ['office', 'office-docx', 'office-pptx', 'office-xlsx']) + await send(agent, 'Edit the Office files without visual inspection.') + const events = agent.session.snapshotEvents() + expect(JSON.stringify(events)).toContain('uniformly blank') + expectNoRenderContext(events) +}) diff --git a/packages/skill/skill-office/tests/render-context.spec.ts b/packages/skill/skill-office/tests/render-context.spec.ts new file mode 100644 index 0000000000..9a22c46612 --- /dev/null +++ b/packages/skill/skill-office/tests/render-context.spec.ts @@ -0,0 +1,45 @@ +/** Render detection uses recorded tool names, not JSON spelling or quoted skill instructions. */ +import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { expect, it } from 'vitest' +import { expectNoRenderContext, officeCalls } from './fixtures/real-office.ts' + +it.each([undefined, 2])('rejects a native render with JSON indentation %s', (indent) => { + const session = Session.create(SessionId('native-render')) + session.append('tool/call', { + turn: 1, step: 1, callId: ToolCallId('render'), name: 'office_render', + arguments: JSON.stringify({ input: 'input.docx' }, null, indent), + }) + expect(officeCalls(session.snapshotEvents())).toEqual([ + { callId: 'render', name: 'office_render', args: { input: 'input.docx' } }, + ]) + expect(() => { expectNoRenderContext(session.snapshotEvents()) }).toThrow() +}) + +it('rejects a PTC render even without a model-visible PTC result', () => { + const session = Session.create(SessionId('ptc-render')) + session.append('tool/ptc-dispatch', { + rootCallId: ToolCallId('program'), parentCallId: ToolCallId('program'), subCallId: ToolCallId('program:1'), + name: 'office_render', arguments: { input: 'input.docx' }, isError: false, content: [], + }) + expect(session.deriveMessages()).toEqual([]) + expect(officeCalls(session.snapshotEvents())).toEqual([ + { callId: 'program:1', name: 'office_render', args: { input: 'input.docx' } }, + ]) + expect(() => { expectNoRenderContext(session.snapshotEvents()) }).toThrow() +}) + +it.each(['office_convert', 'office_recalculate'])('accepts %s and quoted render instructions without a render call', (name) => { + const session = Session.create(SessionId('pdf-conversion')) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'For visual inspection use office_render.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('tool/call', { + turn: 1, step: 1, callId: ToolCallId('output'), name, + arguments: JSON.stringify({ input: 'input.xlsx', format: 'xlsx' }), + }) + expect(officeCalls(session.snapshotEvents())).toEqual([ + { callId: 'output', name, args: { input: 'input.xlsx', format: 'xlsx' } }, + ]) + expectNoRenderContext(session.snapshotEvents()) +}) diff --git a/packages/skill/skill-office/tests/render-tools.e2e.ts b/packages/skill/skill-office/tests/render-tools.e2e.ts index 6225f72c8b..c6f085f40e 100644 --- a/packages/skill/skill-office/tests/render-tools.e2e.ts +++ b/packages/skill/skill-office/tests/render-tools.e2e.ts @@ -1,4 +1,4 @@ -/** Actual skill discovery and CLI rendering through the shipped headless composition. */ +/** Actual skill discovery and Office tool operations through the shipped headless composition. */ import { readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -6,7 +6,7 @@ import { execa } from 'execa' import { expect, it } from 'vitest' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { expectNoRenderContext, officeModelPatches } from './fixtures/real-office.ts' +import { expectNoRenderContext, officeCalls, officeModelPatches, officeToolPatches } from './fixtures/real-office.ts' const repo = fileURLToPath(new URL('../../../../', import.meta.url)) const runtime = process.env.DSH_PRIMARY_RUNTIME @@ -23,6 +23,7 @@ async function prepareOffice(cwd: string) { await writeFile(join(cwd, 'office.patch.json'), JSON.stringify([ ...officeModelPatches(), { insert: [{ id: 'skill-office', name: '@deepseek-ai/dsh-skill-office', config: { assetRoot: join(repo, 'packages/skill/skill-office/assets') } }] }, + ...officeToolPatches(), { insert: [{ id: 'workspace-dependencies', name: '@deepseek-ai/dsh-tool-workspace-dependencies', config: { source: runtime! } }] }, { id: 'session-persistence-jsonl', config: { root: join(cwd, '.sessions'), compression: 'none' } }, ])) @@ -36,15 +37,15 @@ async function sessionEvents(cwd: string) { return events } -it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each([{ format: 'docx', mode: 'native' }, { format: 'pdf', mode: 'native' }, { format: 'docx', mode: 'ptc' }])('loads the operation skill and renders $format through the shell in $mode mode', async ({ format, mode }) => { +it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each([{ format: 'docx', mode: 'native' }, { format: 'pdf', mode: 'native' }, { format: 'docx', mode: 'ptc' }])('renders $format with office_render in $mode mode', async ({ format, mode }) => { let original: Buffer | undefined await runLoaderSmoke({ - label: 'Office CLI rendering', tempDirPrefix: 'dsh-office-render-', + label: 'Office rendering', tempDirPrefix: 'dsh-office-render-', ...launch, binArgs: ['--profile', 'headless', '--patch', 'office.patch.json', ...mode === 'ptc' ? ['--patch', join(repo, 'apps/cli/config/examples/ptc.yml')] : [], // Source-content verification can require an authoring skill; this case covers image-only inspection. - `Export page 1 of input.${format} as PNG into preview. Check only that the exported image is readable; ` + `Render page 1 of input.${format} and check only that the page image is readable; ` + 'do not inspect the source document content or metadata, and keep the source file unchanged. Report briefly in English.'], async prepare(cwd) { await execa(python!, ['-c', "from docx import Document\nd=Document()\nd.add_heading('Quarterly revenue',0)\nd.add_paragraph('Q1: 12; Q2: 18; Total: 30')\nd.save('input.docx')"], { cwd }) @@ -60,33 +61,15 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each([{ format: 'docx', mod if (event.type === 'tool/ptc-dispatch') return [{ name: event.data.name, arguments: JSON.stringify(event.data.arguments) }] return [] }) - const skills = calls.filter(call => call.name === 'skill').map(call => JSON.parse(call.arguments) as { name: string }) - expect(skills.some(skill => skill.name === 'office-render'), JSON.stringify(skills)).toBe(true) - const commands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh').map(call => (JSON.parse(call.arguments) as { command: string }).command).join('\n') - expect(commands).toMatch(/\srender\b/u) - expect(commands).toContain('--pages') - expect(commands).toContain('--output-dir') - const pixels = await execa(python!, ['-c', "from pathlib import Path\nfrom PIL import Image\nfiles=list(Path('preview').glob('*.png'))\nassert files\nfor f in files:\n im=Image.open(f).convert('RGBA')\n visible=Image.alpha_composite(Image.new('RGBA',im.size,'white'),im).convert('RGB')\n print('content' if any(a!=b for a,b in visible.getextrema()) else 'blank')"], { cwd }) - expect(pixels.exitCode).toBe(0) - if (format === 'pdf') expect(pixels.stdout.trim()).toBe('content') - const final = events.filter(e => e.type === 'assistant/message').at(-1)?.data.message.content - .filter(b => b.type === 'text').map(b => b.text).join(' ') - const diagnostic = JSON.stringify({ - format, mode, callOrder: calls.map(call => call.name), skills, commands, pixels: pixels.stdout, final, - }, null, 2) - const authoring = calls.findIndex(call => call.name === 'skill' && /office-(docx|pptx|xlsx)/u.test(call.arguments)) - if (pixels.stdout.includes('blank')) { - if (authoring !== -1) { - const rendered = calls.findIndex(call => (call.name === 'bash' || call.name === 'pwsh') && /\srender\b/u.test(call.arguments)) - expect(authoring, 'content checks after a blank preview may need the format skill').toBeGreaterThan(rendered) - } - expect(final, diagnostic).toMatch( - /visual (?:inspection|check)[^.!?]{0,100}(?:not completed|incomplete|could not be completed|unverified)/iu, - ) - } else { - expect(authoring, diagnostic).toBe(-1) - } - expect(calls.some(e => e.name === 'read_image')).toBe(true) + const renders = officeCalls(events).filter(call => call.name === 'office_render') + expect(renders.length, JSON.stringify(calls)).toBeGreaterThan(0) + const renderCallIds = new Set(renders.map(call => call.callId)) + const images = events.some(event => mode === 'native' + ? event.type === 'tool/result' && renderCallIds.has(event.data.message.toolCallId) + && !event.data.message.isError && event.data.message.content.some(block => block.type === 'image') + : event.type === 'user/message' && event.data.source.kind === 'ptc-mode' + && event.data.content.some(block => block.type === 'image')) + expect(images, JSON.stringify(calls)).toBe(true) }, }) }) @@ -109,7 +92,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime)('keeps Office skills and th }) }) -it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime)('exports PDF through the operation skill and preserves the source', async () => { +it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime)('exports PDF with office_convert and preserves the source', async () => { let original: Buffer | undefined await runLoaderSmoke({ ...launch, @@ -123,18 +106,19 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime)('exports PDF through the op }, async inspect(cwd) { const events = await sessionEvents(cwd) - expectNoRenderContext(events) const calls = events.flatMap(event => event.type === 'tool/call' ? [event.data] : event.type === 'tool/ptc-dispatch' ? [{ name: event.data.name, arguments: JSON.stringify(event.data.arguments) }] : []) const skills = calls.filter(call => call.name === 'skill').map(call => (JSON.parse(call.arguments) as { name: string }).name) - expect(skills).toContain('office-export-pdf') - expect(skills).not.toEqual(expect.arrayContaining(['office-render'])) - expect(skills.some(name => ['office-docx', 'office-pptx', 'office-xlsx'].includes(name))).toBe(false) + const office = officeCalls(events) + const diagnostic = JSON.stringify({ calls: calls.map(call => call.name), skills, + office: office.map(call => ({ name: call.name, input: call.args.input, format: call.args.format })) }) + expect(skills.some(name => ['office-docx', 'office-pptx', 'office-xlsx'].includes(name)), diagnostic).toBe(false) const commands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh') .map(call => (JSON.parse(call.arguments) as { command: string }).command).join('\n') - expect(commands).toMatch(/\sconvert\b/u) - expect(commands).not.toMatch(/\srender\b/u) - expect(commands).not.toMatch(/pypdf|PyPDF2|\bfitz\b|pdfminer|pdfplumber|pdfinfo|pdftotext/iu) + expect(office.some(call => call.name === 'office_convert' && call.args.format === 'pdf'), diagnostic).toBe(true) + expect(office.some(call => call.name === 'office_render'), diagnostic).toBe(false) + expectNoRenderContext(events) + expect(commands, diagnostic).not.toMatch(/pypdf|PyPDF2|\bfitz\b|pdfminer|pdfplumber|pdfinfo|pdftotext/iu) expect((await readFile(join(cwd, 'result.pdf'))).subarray(0, 5).toString()).toBe('%PDF-') expect(await readFile(join(cwd, 'input.docx'))).toEqual(original) }, diff --git a/packages/skill/skill-office/tests/skill-office.spec.ts b/packages/skill/skill-office/tests/skill-office.spec.ts index 75adb8f00d..035ebe9e29 100644 --- a/packages/skill/skill-office/tests/skill-office.spec.ts +++ b/packages/skill/skill-office/tests/skill-office.spec.ts @@ -1,4 +1,4 @@ -import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -7,14 +7,17 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillOffice from '@deepseek-ai/dsh-skill-office' -import { isSea } from 'node:sea' -import { execa } from 'execa' import { describe, expect, it, vi } from 'vitest' -vi.mock('node:sea', () => ({ isSea: vi.fn(() => false) })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFile: vi.fn(actual.readFile) } +}) const assets = fileURLToPath(new URL('../assets/', import.meta.url)) -const names = ['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx'] +const userPython = 'Follow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise use the project\'s or user\'s existing Python, such as `python3` or `python` on PATH. Before the first script, check that this Python can import the libraries the workflow needs. If Python or a library is missing, tell the user which packages to install; do not install packages into a global environment without the user\'s agreement.' +const workspacePython = 'Follow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise call `load_workspace_dependencies` and run scripts with its Python executable and bundled libraries. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.' +const names = ['office', 'office-docx', 'office-pptx', 'office-xlsx'] describe('bundled Office skills', () => { it('keeps routing and authoring within the progressive context budgets', async () => { @@ -25,14 +28,16 @@ describe('bundled Office skills', () => { const catalog = await ctx.skills.list() expect(catalog.map(skill => skill.name)).toEqual(names) const route = (await ctx.skills.get('office'))! + expect(route.description).toBe('Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.') expect(Math.ceil(route.description.length / 4)).toBeLessThanOrEqual(50) expect(Math.ceil(route.content.length / 4)).toBeLessThanOrEqual(200) for (const [name, previousTokens] of [['office-docx', 1688], ['office-pptx', 1879], ['office-xlsx', 2330]] as const) { const skill = (await ctx.skills.get(name))! - expect(Math.ceil(skill.content.length / 4)).toBeLessThanOrEqual(Math.floor(previousTokens * 0.65)) - expect(skill.content).not.toMatch(/read_image|transparent|DPI/) + expect(Math.ceil(skill.content.length / 4)).toBeLessThanOrEqual(Math.floor(previousTokens * 0.7)) + expect(skill.content).not.toMatch(/read_image/) expect(skill.content).not.toContain('dsoffice') - expect(skill.content).toContain('office-render') + expect(skill.content).toContain('`office_render`') + expect(skill.content).toContain('"Content and structure checks passed; visual inspection was not completed." or "内容与结构检查通过;视觉检查未完成。"') } } finally { await ctx.fiber.dispose() } }) @@ -51,8 +56,8 @@ describe('bundled Office skills', () => { expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(assets, skill.name).split(sep).join('/') }) const raw = await readFile(join(assets, skill.name, 'SKILL.md'), 'utf8') const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, '').trim() - expect(loaded?.content.startsWith(body)).toBe(true) - if (!['office-render', 'office-recalculate', 'office-export-pdf'].includes(skill.name)) expect(loaded?.content).toBe(body) + const expected = skill.name === 'office' ? body : `${body}\n\n## Python environment\n\n${workspacePython}` + expect(loaded?.content).toBe(expected) } await fiber.dispose() expect(await ctx.skills.list()).toEqual([]) @@ -101,13 +106,36 @@ describe('bundled Office skills', () => { expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(external, 'office-xlsx').split(sep).join('/') }) const raw = await readFile(join(external, 'office-xlsx', 'SKILL.md'), 'utf8') const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, '').trim() - expect(loaded?.content).toBe(body) + expect(loaded?.content).toBe(`${body}\n\n## Python environment\n\n${workspacePython}`) } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) } }) + it.each([ + ['defaults to', undefined, workspacePython], + ['accepts', 'user', userPython], + ['accepts', 'workspace-dependencies', workspacePython], + ] as const)('%s the %s Python environment for Python workflows only', async (_verb, pythonEnvironment, section) => { + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + await ctx.plugin(SkillOffice, pythonEnvironment === undefined ? {} : { pythonEnvironment }) + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + const content = (await ctx.skills.get(name))!.content + expect(content).toContain(`\n\n## Python environment\n\n${section}`) + expect(content).not.toContain(section === userPython ? workspacePython : userPython) + } + expect((await ctx.skills.get('office'))!.content).not.toContain('## Python environment') + } finally { await ctx.fiber.dispose() } + }) + + it('rejects an unknown Python environment', async () => { + // Loader input is untyped YAML; call the schema the same way. + expect(() => { Reflect.apply(SkillOffice.Config, undefined, [{ pythonEnvironment: 'system' }]) }).toThrow('pythonEnvironment') + }) + it.each([ ['description: "Quoted: description"', '\r\n', 'Quoted: description'], ['description: >-\n Folded\n description', '\n', 'Folded description'], @@ -122,29 +150,157 @@ describe('bundled Office skills', () => { await ctx.plugin(SkillOffice, { assetRoot: root }) const skill = await ctx.skills.get('office-docx') expect(skill?.description).toBe(description) - expect(skill?.content).toBe(body.replaceAll('\n', newline)) + expect(skill?.content).toBe(`${body.replaceAll('\n', newline)}\n\n## Python environment\n\n${workspacePython}`) } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) } }) - it('rejects relative resource paths and incomplete asset trees before registration', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-office-invalid-')) + it('rejects a relative asset root before registration', async () => { const ctx = new Context() try { await ctx.plugin(SkillRegistry) - expect(() => { SkillOffice.apply(ctx, { assetRoot: 'assets' }) }).toThrow('absolute directory') - await cp(assets, root, { recursive: true }) - await rm(join(root, 'office-docx', 'SKILL.md')) - expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('SKILL.md') - await writeFile(join(root, 'office-docx', 'SKILL.md'), '# Missing frontmatter\ndescription: body text\n') - expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('has no YAML frontmatter') - for (const header of ['', 'null', 'scalar', 'name: office-docx', 'description: 3', 'description: ""']) { - await writeFile(join(root, 'office-docx', 'SKILL.md'), `---\n${header}\n---\n# Instructions\n`) - expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('has no description') - } + expect(() => { SkillOffice.apply(ctx, { assetRoot: 'assets', pythonEnvironment: 'user' }) }).toThrow('absolute directory') expect(await ctx.skills.list()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + + it('omits unreadable or invalid skills from a complete catalog and reports each failure once', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-office-invalid-')) + const ctx = new Context() + const warnings: string[] = [] + try { + ctx.logger.exporter({ levels: { default: 2 }, export: ({ type, args }) => { if (type === 'warn') warnings.push(String(args[0])) } }) + await ctx.plugin(SkillRegistry) + await cp(assets, root, { recursive: true }) + const docx = join(root, 'office-docx', 'SKILL.md') + const original = await readFile(docx, 'utf8') + const remaining = names.filter(name => name !== 'office-docx') + const catalog = async () => { + const snapshot = await ctx.skills.snapshot() + return { complete: snapshot.complete, names: snapshot.skills.map(skill => skill.name) } + } + await rm(docx) + let fiber = await ctx.plugin(SkillOffice, { assetRoot: root }) + expect(await catalog()).toEqual({ complete: true, names: remaining }) + expect((await ctx.skills.get('office-xlsx'))?.content).toContain('openpyxl') + expect(warnings).toEqual([expect.stringMatching(/^skill-office: skill "office-docx" is unavailable: Error: ENOENT.*SKILL\.md/u)]) + await fiber.dispose() + const invalid: [content: string, failure: string][] = [ + ['# Missing frontmatter\ndescription: body text\n', 'has no YAML frontmatter'], + ...['', 'null', 'scalar', 'name: office-docx', 'description: 3', 'description: ""'] + .map((header): [string, string] => [`---\n${header}\n---\n# Instructions\n`, 'has no description']), + // YAML parse errors carry their own codes; they persist like other invalid metadata. + ['---\ndescription: [unclosed\n---\n# Instructions\n', 'YAMLParseError'], + ] + for (const [content, failure] of invalid) { + await writeFile(docx, content) + fiber = await ctx.plugin(SkillOffice, { assetRoot: root }) + expect(await catalog()).toEqual({ complete: true, names: remaining }) + expect(warnings.at(-1)).toContain(failure) + await fiber.dispose() + } + await writeFile(docx, original) + const registerProvider = vi.spyOn(ctx.skills, 'registerProvider') + await ctx.plugin(SkillOffice, { assetRoot: root }) + expect(await catalog()).toEqual({ complete: true, names }) + const reported = warnings.length + await rm(docx) + expect(await ctx.skills.get('office-docx')).toBeUndefined() + expect(warnings).toHaveLength(reported + 1) + expect(await catalog()).toEqual({ complete: true, names: remaining }) + expect(warnings).toHaveLength(reported + 1) + const provider = registerProvider.mock.calls[0]![0]({ signal: new AbortController().signal, invalidate: () => {} }) + await expect(provider.list({ signal: AbortSignal.abort() })).rejects.toMatchObject({ name: 'AbortError' }) + expect(warnings).toHaveLength(reported + 1) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + + it('omits a skill whose frontmatter becomes malformed YAML after discovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-office-malformed-')) + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + await cp(assets, root, { recursive: true }) + await ctx.plugin(SkillOffice, { assetRoot: root }) + expect((await ctx.skills.snapshot()).complete).toBe(true) + await writeFile(join(root, 'office-docx', 'SKILL.md'), '---\ndescription: [unclosed\n---\n# Instructions\n') + expect(await ctx.skills.get('office-docx')).toBeUndefined() + const snapshot = await ctx.skills.snapshot() + expect({ complete: snapshot.complete, names: snapshot.skills.map(skill => skill.name) }) + .toEqual({ complete: true, names: names.filter(name => name !== 'office-docx') }) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + + it.each([ + ['an Error without a code', new Error('temporary read failure')], + ['a non-Error rejection', 'temporary read failure'], + ])('retries discovery and preserves load failures for %s', async (_label, error) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-office-unknown-read-')) + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + await cp(assets, root, { recursive: true }) + const docx = join(root, 'office-docx', 'SKILL.md') + const actual = await vi.importActual('node:fs/promises') + const read = vi.mocked(readFile) + let failRead = true + try { + read.mockImplementation((path, options) => path === docx && failRead + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercise primitive read failures. + ? Promise.reject(error) : actual.readFile(path, options)) + await ctx.plugin(SkillOffice, { assetRoot: root }) + const incomplete = await ctx.skills.snapshot() + expect({ complete: incomplete.complete, names: incomplete.skills.map(skill => skill.name) }) + .toEqual({ complete: false, names: names.filter(name => name !== 'office-docx') }) + failRead = false + const recovered = await ctx.skills.snapshot() + expect({ complete: recovered.complete, names: recovered.skills.map(skill => skill.name) }) + .toEqual({ complete: true, names }) + failRead = true + await expect(ctx.skills.get('office-docx')).rejects.toBe(error) + } finally { + read.mockReset() + } + expect((await ctx.skills.get('office-docx'))?.content).toContain('python-docx') + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + + it('retries read errors other than a missing path instead of omitting the skill', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-office-retry-')) + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + await cp(assets, root, { recursive: true }) + const docx = join(root, 'office-docx', 'SKILL.md') + const original = await readFile(docx, 'utf8') + const catalog = async () => { + const snapshot = await ctx.skills.snapshot() + return { complete: snapshot.complete, names: snapshot.skills.map(skill => skill.name) } + } + await rm(docx) + // Reading a directory fails with EISDIR, standing in for a lock or permission error. + await mkdir(docx) + await ctx.plugin(SkillOffice, { assetRoot: root }) + expect(await catalog()).toEqual({ complete: false, names: names.filter(name => name !== 'office-docx') }) + await rm(docx, { recursive: true }) + await writeFile(docx, original) + expect(await catalog()).toEqual({ complete: true, names }) + await rm(docx) + await mkdir(docx) + await expect(ctx.skills.get('office-docx')).rejects.toMatchObject({ code: 'EISDIR' }) } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) @@ -152,57 +308,23 @@ describe('bundled Office skills', () => { }) }) -it('executes capabilities using only paths returned by the loaded skill from a separate workspace', async () => { - const root = await mkdtemp(join(tmpdir(), 'office CLI 工作目录 ')) +it('directs Office operations to dedicated tools without executable paths', async () => { const ctx = new Context() try { await ctx.plugin(SkillRegistry) await ctx.plugin(SkillOffice) - const skill = await ctx.skills.get('office-render') - const json = skill?.content.match(/\n(\{\n[\s\S]+)$/u)?.[1] - expect(json).toBeDefined() - const { libreofficeKit } = JSON.parse(json!) as { libreofficeKit: { node: string; cli: string } } - const result = await execa(libreofficeKit.node, [libreofficeKit.cli, 'capabilities'], { - cwd: root, env: { PATH: '' }, timeout: 20_000, killSignal: 'SIGKILL', reject: false, - }) - expect(result.timedOut, result.stderr).toBe(false) - expect(result.signal, result.stderr).toBeUndefined() - expect(result.exitCode, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ runtime: { version: '0.1.5', cliPath: libreofficeKit.cli } }) - } finally { - await ctx.fiber.dispose() - await rm(root, { recursive: true, force: true }) - } -}) - - -it('validates operation paths on loading while authoring remains available', async () => { - const ctx = new Context() - const original = Object.getOwnPropertyDescriptor(process.versions, 'electron') - try { - await ctx.plugin(SkillRegistry) - for (const config of [{ node: 'node' }, { cli: 'cli.js' }, { node: assets }, { cli: assets }, { cli: join(assets, 'missing.js') }]) { - const fiber = await ctx.plugin(SkillOffice, config) - expect((await ctx.skills.get('office-docx'))?.content).toContain('python-docx') - await expect(ctx.skills.get('office-render')).rejects.toThrow() - await fiber.dispose() + for (const name of names) { + const content = (await ctx.skills.get(name))!.content + expect(content).toContain('If `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step.') + expect(content).not.toMatch( + /operation:|`office` tool|office-render|office-recalculate|office-export-pdf|LibreOffice Kit CLI|--output-dir|""/u, + ) } - const carrier = await ctx.plugin(SkillOffice) - vi.mocked(isSea).mockReturnValue(true) - await expect(ctx.skills.get('office-render')).rejects.toThrow('standalone Node') - vi.mocked(isSea).mockReturnValue(false) - Object.defineProperty(process.versions, 'electron', { value: '44.0.0', configurable: true }) - await expect(ctx.skills.get('office-render')).rejects.toThrow('standalone Node') - await carrier.dispose() - const disabled = await ctx.plugin(SkillOffice, { cli: false }) - expect((await ctx.skills.get('office-render'))?.content).toContain('disabled in this deployment') - await disabled.dispose() - await ctx.plugin(SkillOffice, { node: process.execPath }) - expect((await ctx.skills.get('office-render'))?.content).toContain(JSON.stringify(process.execPath)) - } finally { - vi.mocked(isSea).mockReturnValue(false) - if (original === undefined) Reflect.deleteProperty(process.versions, 'electron') - else Object.defineProperty(process.versions, 'electron', original) - await ctx.fiber.dispose() - } + const route = (await ctx.skills.get('office'))!.content + for (const instruction of ['`office_render`', '`office_recalculate`', '`office_convert` with `format`', '`format: "xlsx"` or `"ods"`']) expect(route).toContain(instruction) + expect((await ctx.skills.get('office-xlsx'))!.content).toContain('`office_recalculate` with `format: "xlsx"`') + for (const [format, legacy, library] of [['docx', 'doc', 'python-docx'], ['xlsx', 'xls', 'openpyxl'], ['pptx', 'ppt', 'python-pptx']]) { + expect((await ctx.skills.get(`office-${format}`))!.content).toContain(`${library} cannot read legacy \`.${legacy}\` files. Use \`office_convert\` with \`format: "${format}"\`, copy the result into the workspace, then edit the copy.`) + } + } finally { await ctx.fiber.dispose() } }) diff --git a/packages/skill/skill-office/tests/visual-intent-pptx.e2e.ts b/packages/skill/skill-office/tests/visual-intent-pptx.e2e.ts index f5959df776..d29f38a17d 100644 --- a/packages/skill/skill-office/tests/visual-intent-pptx.e2e.ts +++ b/packages/skill/skill-office/tests/visual-intent-pptx.e2e.ts @@ -1,4 +1,4 @@ -/** Real Office visual-intent e2e cases for PPTX documents, including the blank-preview case. */ +/** Real Office visual-intent e2e cases for PPTX documents. */ import { registerVisualIntentCases } from './fixtures/visual-intent.ts' -registerVisualIntentCases(['pptx'], { blank: true }) +registerVisualIntentCases(['pptx']) diff --git a/packages/skill/skill-office/tests/xlsx-validation.e2e.ts b/packages/skill/skill-office/tests/xlsx-validation.e2e.ts index bf3cdf218e..1befea8162 100644 --- a/packages/skill/skill-office/tests/xlsx-validation.e2e.ts +++ b/packages/skill/skill-office/tests/xlsx-validation.e2e.ts @@ -9,8 +9,7 @@ import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { readImageFile } from '@deepseek-ai/dsh-attachment-local' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { expectNoRenderContext, officeModelPatches } from './fixtures/real-office.ts' -import { renderCliFixture } from './fixtures/render-cli.ts' +import { expectNoRenderContext, officeCalls, officeModelPatches, officeToolPatches } from './fixtures/real-office.ts' const repo = fileURLToPath(new URL('../../../../', import.meta.url)) const assetRoot = fileURLToPath(new URL('../assets/', import.meta.url)) @@ -25,8 +24,8 @@ s = w.active s.title = 'Revenue' for row in [('Quarterly revenue', 'Amount'), ('Q1', 12), ('Q2', 18)]: s.append(row) -s.column_dimensions['A'].width = 5 if sys.argv[1] in ('layout', 'blank') else 24 -s.column_dimensions['B'].width = 5 if sys.argv[1] in ('layout', 'blank') else 12 +s.column_dimensions['A'].width = 5 if sys.argv[1] == 'layout' else 24 +s.column_dimensions['B'].width = 5 if sys.argv[1] == 'layout' else 12 w.create_sheet('Notes')['A1'] = 'Preserve this note' w.save('input.xlsx') ` @@ -55,11 +54,6 @@ const cases = [ task: 'Fix the cramped layout of Revenue!A1:B3: make the column headers fully readable and the amounts easy to read. Adjust column widths and header styling, then visually inspect the final affected range.', kind: 'layout', }, - { - name: 'blank previews end visual checking without changing print settings', - task: 'Fix the cramped layout of Revenue!A1:B3: make the column headers fully readable and the amounts easy to read. Adjust column widths and header styling, then visually inspect the final affected range.', - kind: 'blank', - }, ] as const describe.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(['native', 'ptc'])('Excel validation scope (%s)', (mode) => { @@ -67,7 +61,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(['native', 'ptc' let events: SessionEvent[] = [] let original: Buffer | undefined let previewHasContent = false - let fixtureCalls: { args: string[]; status: number | null }[] = [] const controlRoot = await mkdtemp(join(tmpdir(), 'dsh-xlsx-control-test-')) onTestFinished(() => rm(controlRoot, { recursive: true, force: true })) const configPath = join(controlRoot, 'office.patch.json') @@ -96,10 +89,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(['native', 'ptc' async prepare(cwd) { await execa(python!, ['-c', seed, kind], { cwd }) original = await readFile(join(cwd, 'input.xlsx')) - const fixture = kind === 'blank' || kind === 'formula' - ? await renderCliFixture(controlRoot, python!, kind === 'blank') : undefined await writeFile(configPath, JSON.stringify([ - { insert: [{ id: 'skill-office', name: '@deepseek-ai/dsh-skill-office', config: { assetRoot, node: process.execPath, ...fixture ? { cli: fixture.cli } : {} } }] }, + { insert: [{ id: 'skill-office', name: '@deepseek-ai/dsh-skill-office', config: { assetRoot } }] }, + ...officeToolPatches(), { insert: [{ id: 'workspace-dependencies', name: '@deepseek-ai/dsh-tool-workspace-dependencies', config: { source: runtime! } }] }, ...officeModelPatches(), { id: 'attachment-local', config: { dshHome: imageRoot } }, @@ -126,7 +118,7 @@ assert w.sheetnames == ['Revenue', 'Notes'] assert w['Notes']['A1'].value == 'Preserve this note' assert s['B3'].value == 18 kind = '${kind}' -if kind in ('layout', 'blank'): +if kind == 'layout': assert s['B2'].value == 12 assert s.column_dimensions['A'].width > 5 assert s.column_dimensions['B'].width > 5 @@ -145,28 +137,11 @@ else: assert load_workbook('result.xlsx', data_only=True)['Revenue']['B4'].value == 38 `], { cwd }) expect(verified.exitCode).toBe(0) - if (kind === 'blank' || kind === 'formula') { - fixtureCalls = (await readFile(join(controlRoot, '.fixture/calls.jsonl'), 'utf8')).trim().split('\n') - .map(line => JSON.parse(line) as { args: string[]; status: number | null }) - if (kind === 'blank') expect(fixtureCalls.some(call => call.args[0] === 'convert')).toBe(false) - else expect(fixtureCalls.some(call => call.args[0] === 'recalculate' && call.status === 0)).toBe(true) - } + if (kind === 'formula') expect(officeCalls(events).some(call => call.name === 'office_recalculate')).toBe(true) const imageBlocks = events.flatMap(event => event.type === 'tool/result' ? event.data.message.content.filter(block => block.type === 'image') : event.type === 'user/message' && event.data.source.kind === 'ptc-mode' ? event.data.content.filter(block => block.type === 'image') : []) - if (kind === 'layout' && imageBlocks.length === 0) { - const kit = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/cli')) - const previewDir = join(cwd, '.verification-preview') - await execa(process.execPath, [kit, 'render', '--input', join(cwd, 'result.xlsx'), - '--output-dir', previewDir, '--sheet', 'Revenue', '--range', 'A1:B3'], { cwd }) - const pngs = (await readdir(previewDir, { recursive: true })).filter(path => path.endsWith('.png')) - expect(pngs.length).toBeGreaterThan(0) - for (const path of pngs) { - const pixels = await execa(python!, ['-c', previewPixels], { input: await readFile(join(previewDir, path)) }) - previewHasContent ||= pixels.stdout === 'content' - } - } for (const block of imageBlocks) { const stored = await readImageFile(join(imageRoot, 'attachments/v1'), block.attachment) const pixels = await execa(python!, ['-c', previewPixels], { input: Buffer.from(stored.data) }) @@ -186,16 +161,11 @@ else: ? answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n').slice(0, 800) : undefined }) expect(loaded, diagnostic).toContain('office-xlsx') expect(calls.some(call => call.name === 'load_workspace_dependencies'), diagnostic).toBe(true) - if (kind === 'formula') { - expect(loaded, diagnostic).toContain('office-recalculate') - } - if (kind === 'layout' || kind === 'blank') { - expect(calls.some(call => call.name === 'skill' && call.arguments.includes('office-render'))).toBe(true) - expect(commands).toMatch(/\srender\b/u) - expect(commands).toContain('--sheet') - expect(commands).toContain('--range') - expect(commands).not.toMatch(/\sconvert\b/u) - const reads = calls.filter(call => call.name === 'read_image') + if (kind === 'layout') { + const office = officeCalls(events) + expect(office.some(call => call.name === 'office_render' && 'sheet' in call.args && 'range' in call.args), diagnostic).toBe(true) + expect(office.some(call => call.name === 'office_convert'), diagnostic).toBe(false) + const reads = calls.filter(call => call.name === 'office_render') const results = events.flatMap(event => event.type === 'tool/result' ? [event.data.message] : []) if (previewHasContent) { expect(reads.length).toBeGreaterThan(0) @@ -206,7 +176,6 @@ else: && event.data.content.some(block => block.type === 'image')) expect(visibleImage).toBe(true) } - if (kind === 'blank') expect(previewHasContent).toBe(false) if (!previewHasContent) { const answer = events.findLast(event => event.type === 'assistant/message') if (answer?.type !== 'assistant/message') throw new Error('Agent did not finish with an answer') @@ -215,16 +184,10 @@ else: expect(text).toMatch(/visual (?:inspection|check)[^.!?]{0,100}(?:not completed|incomplete|could not be completed|unverified)/iu) expect(text).not.toMatch(/visually (?:verified|validated|confirmed)|visual (?:check|inspection) (?:passed|successful)/iu) expect(reads.length).toBeLessThanOrEqual(1) - if (kind === 'blank') { - const firstBlank = fixtureCalls.findIndex(call => call.args[0] === 'render' && call.status === 0) - expect(firstBlank, JSON.stringify(fixtureCalls)).toBeGreaterThanOrEqual(0) - expect(fixtureCalls.slice(firstBlank + 1).filter(call => call.args[0] === 'render'), JSON.stringify(fixtureCalls)).toEqual([]) - } } } else { expectNoRenderContext(events) - expect(calls.some(call => call.name === 'read_image')).toBe(false) - expect(calls.some(call => call.name === 'skill' && call.arguments.includes('office-render'))).toBe(false) + expect(officeCalls(events).some(call => call.name === 'office_render')).toBe(false) expect(commands).not.toMatch(/(? event.type === 'assistant/message') if (answer?.type !== 'assistant/message') throw new Error('Agent did not finish with an answer') diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 7cbfb17d91..7b773a1ae1 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 22a229424b..cab7866366 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml index adf2113745..f40ab2be63 100644 --- a/packages/skill/tool-workspace-dependencies/README.i18n.yaml +++ b/packages/skill/tool-workspace-dependencies/README.i18n.yaml @@ -15,17 +15,17 @@ en: d152484eb41ac6b4 zh: 43ad67236ccda19a /deepseek-ai-dsh-tool-workspace-dependencies/use-this-package: - en: 5e244ff5e354bc47 - zh: 2ab0c4288708a59c + en: d847797c4f42fe96 + zh: 947fff5718bed313 /deepseek-ai-dsh-tool-workspace-dependencies/use-this-package/minimal-configuration: en: 97b1f99b4e9c2569 zh: bf6c6ce74073413f /deepseek-ai-dsh-tool-workspace-dependencies/use-this-package/payload-layout: - en: 709eda9a01bc0a81 - zh: bb4693ddd755866a + en: 0575105cbe07c108 + zh: 125d54d45a72bf43 /deepseek-ai-dsh-tool-workspace-dependencies/use-this-package/build-a-carrier-payload: - en: 0f3e570525053f09 - zh: 1b0cd3b653e1bd5d + en: 40e14ab104153a9d + zh: 34da896f77184227 /deepseek-ai-dsh-tool-workspace-dependencies/understand-the-implementation: en: 0121e57a754cb26f zh: 2933fc0415da013d diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md index ad1aa24f40..ba0e68c7c5 100644 --- a/packages/skill/tool-workspace-dependencies/README.md +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -25,7 +25,7 @@ Deployments that ship their own script runtimes (Desktop's primary runtime, or a ## Use this package -Mount the plugin beside the tool registry with the payload directory. Configuration validation requires a nonempty `source` and rejects empty `root` values before activation; both paths must be absolute. The bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. +Mount the plugin beside the tool registry with the payload directory. Configuration validation requires a nonempty `source` and rejects empty `root` values before activation; both paths must be absolute. With `pythonEnvironment: 'workspace-dependencies'`, the bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. ### Minimal configuration @@ -46,13 +46,13 @@ Mount the plugin beside the tool registry with the payload directory. Configurat On macOS and Linux, the builder retains `python` and `python3` as relative links to the sibling versioned interpreter (for example, `python3.12`). Installation preserves these two aliases and materializes other links. Alias validation requires the exact sibling filename and a regular target file; carriers that cannot preserve links, including Python wheels, retain supported regular command files. Windows keeps its ordinary `python.exe` file. -The packaged `sdk` profile uses its bundled Python and Office skills by default; `DSH_PRIMARY_RUNTIME` overrides the resource location, and an empty value opts out. Source launches without a carrier default remain opt-in. See [runtime configuration](../../../python/sdk-runtime/README.md) for independent skill selection. Missing skill resources produce a startup warning; invalid or incomplete external runtime payloads fail the first tool call. Profile configuration changes require restarting the SDK process. +The packaged `sdk` profile uses its bundled Python and Office skills by default; `DSH_PRIMARY_RUNTIME` overrides the resource location, and an empty value opts out of the bundled environment. Without a primary runtime, including source launches, the `sdk` profile leaves this tool unmounted and still mounts the Office skills, which direct the model to the user's Python. See [runtime configuration](../../../python/sdk-runtime/README.md) for independent skill selection. Missing skill resources are reported once through `ctx.logger.warn` during catalog discovery; invalid or incomplete external runtime payloads fail the first tool call. Profile configuration changes require restarting the SDK process. ### Build a carrier payload -From a repository checkout with dependencies installed, `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office` writes `primary-runtime/` and `office-skills/`. The shared [download lock](../../../scripts/primary-runtime/lock.json) also covers `linux-arm64`, `mac-arm64`, `mac-x64`, and `win-x64`. `--python-only` omits Node.js and pnpm; `--cache` selects the hash-verified archive cache. The entry executes interpreter and Office read/write checks only for a native target. Cross-target builds require those checks on the target host before deployment. +From a repository checkout with dependencies installed, `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office` writes `primary-runtime/`. The shared [download lock](../../../scripts/primary-runtime/lock.json) also covers `linux-arm64`, `mac-arm64`, `mac-x64`, and `win-x64`. `--python-only` omits Node.js and pnpm; `--cache` selects the hash-verified archive cache. The entry executes interpreter and Office read/write checks only for a native target. Cross-target builds require those checks on the target host before deployment. -A container can copy both directories into an immutable image layer and set `DSH_PRIMARY_RUNTIME` to the absolute `primary-runtime/` path. The SDK queries that payload in place. Desktop uses the same builder and retains its Harness-home installation and signing checks. +A container can copy that directory into an immutable image layer and set `DSH_PRIMARY_RUNTIME` to the absolute `primary-runtime/` path. The SDK queries that payload in place. Desktop uses the same builder and retains its Harness-home installation and signing checks. ----- diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md index 410d936661..c14cada2c5 100644 --- a/packages/skill/tool-workspace-dependencies/README.zh.md +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与工具注册表一起挂载,给出 payload 目录。配置校验在激活前要求非空 `source` 并拒绝空 `root`;两条路径都必须为绝对路径。内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 +与工具注册表一起挂载,给出 payload 目录。配置校验在激活前要求非空 `source` 并拒绝空 `root`;两条路径都必须为绝对路径。配置 `pythonEnvironment: 'workspace-dependencies'` 时,内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 ### 最小配置 @@ -46,13 +46,13 @@ kind: "package-reference" 在 macOS 和 Linux 上,构建器将 `python` 和 `python3` 保留为指向同目录版本化解释器(例如 `python3.12`)的相对链接。安装时保留这两个别名,其他链接展开为实际文件。别名校验要求目标名称与同目录解释器文件名完全一致,且目标为普通文件;Python wheel 等无法保留链接的载体使用普通命令文件,同样受支持。Windows 保留普通的 `python.exe` 文件。 -打包的 `sdk` profile 默认使用随包 Python 和 Office skills;`DSH_PRIMARY_RUNTIME` 覆盖资源位置,空值表示禁用。没有载体默认路径的源码启动仍需显式启用。独立选择 skills 的方式见[运行时配置](../../../python/sdk-runtime/README.zh.md)。缺少 skill 资源会产生启动警告;无效或不完整的外部运行时 payload 在首次工具调用时失败。profile 配置变更需要重启 SDK 进程。 +打包的 `sdk` profile 默认使用随包 Python 和 Office skills;`DSH_PRIMARY_RUNTIME` 覆盖资源位置,空值表示不使用随包环境。没有主运行时(包括源码启动)时,`sdk` profile 不挂载此工具,但仍挂载 Office skills,由它们引导模型使用用户的 Python。独立选择 skills 的方式见[运行时配置](../../../python/sdk-runtime/README.zh.md)。缺少 skill 资源时,目录发现阶段通过 `ctx.logger.warn` 报告一次;无效或不完整的外部运行时 payload 在首次工具调用时失败。profile 配置变更需要重启 SDK 进程。 ### 构建载体 payload -在已安装依赖的仓库 checkout 中运行 `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office`,会生成 `primary-runtime/` 和 `office-skills/`。[共享下载锁](../../../scripts/primary-runtime/lock.json)还覆盖 `linux-arm64`、`mac-arm64`、`mac-x64` 和 `win-x64`。`--python-only` 省略 Node.js 和 pnpm;`--cache` 选择经过哈希校验的归档缓存。入口仅对本机目标执行解释器与 Office 读写检查。跨目标构建必须在部署前到目标主机执行这些检查。 +在已安装依赖的仓库 checkout 中运行 `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office`,会生成 `primary-runtime/`。[共享下载锁](../../../scripts/primary-runtime/lock.json)还覆盖 `linux-arm64`、`mac-arm64`、`mac-x64` 和 `win-x64`。`--python-only` 省略 Node.js 和 pnpm;`--cache` 选择经过哈希校验的归档缓存。入口仅对本机目标执行解释器与 Office 读写检查。跨目标构建必须在部署前到目标主机执行这些检查。 -容器可将这两个目录复制到不可变镜像层,并将 `DSH_PRIMARY_RUNTIME` 设为 `primary-runtime/` 的绝对路径。SDK 原位查询该 payload。Desktop 使用同一构建器,并保留 Harness-home 安装与签名检查。 +容器可将该目录复制到不可变镜像层,并将 `DSH_PRIMARY_RUNTIME` 设为 `primary-runtime/` 的绝对路径。SDK 原位查询该 payload。Desktop 使用同一构建器,并保留 Harness-home 安装与签名检查。 ----- diff --git a/packages/skill/tool-workspace-dependencies/package.json b/packages/skill/tool-workspace-dependencies/package.json index 91e7fc6aed..479d24d75f 100644 --- a/packages/skill/tool-workspace-dependencies/package.json +++ b/packages/skill/tool-workspace-dependencies/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workspace-dependencies", "description": "The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -24,7 +24,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index ea61e08cec..f65ad9d50a 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index d3a9fbc0a2..e544722dda 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Token-budgeted tool-result retention with recoverable text and image paths", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -30,7 +30,10 @@ "locale/*.json", "lib/index.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 0fcc1d99e9..ec156b5a5a 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -23,7 +23,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/ssh/fs-ssh/package.json b/packages/ssh/fs-ssh/package.json index f196aa345a..de38d322fe 100644 --- a/packages/ssh/fs-ssh/package.json +++ b/packages/ssh/fs-ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-ssh", "description": "Filesystem provider over the shared POSIX SSH helper", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -37,7 +40,7 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/ssh/sandbox-ssh/package.json b/packages/ssh/sandbox-ssh/package.json index 406582f1d9..792e726545 100644 --- a/packages/ssh/sandbox-ssh/package.json +++ b/packages/ssh/sandbox-ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-ssh", "description": "Remote POSIX sandbox argv provider over the shared SSH helper", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -35,7 +38,7 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/ssh/ssh-helper-runtime/package.json b/packages/ssh/ssh-helper-runtime/package.json index 34d91db63a..4e37cebe8b 100644 --- a/packages/ssh/ssh-helper-runtime/package.json +++ b/packages/ssh/ssh-helper-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-ssh-helper-runtime", "description": "Private executable carrier for the POSIX SSH helper and managed workers", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "private": true, "type": "module", "main": "lib/index.js", @@ -15,7 +15,10 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/ssh/ssh/package.json b/packages/ssh/ssh/package.json index ee26eb777b..4b5f407dfa 100644 --- a/packages/ssh/ssh/package.json +++ b/packages/ssh/ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-ssh", "description": "Shared OpenSSH connection and versioned POSIX remote helper", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -43,7 +43,10 @@ "lib/protocol-*.js", "lib/schemas-*.js", "lib/stream-security-*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -62,7 +65,7 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/ssh/subprocess-ssh/package.json b/packages/ssh/subprocess-ssh/package.json index 51bafb368c..9c2edac302 100644 --- a/packages/ssh/subprocess-ssh/package.json +++ b/packages/ssh/subprocess-ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-ssh", "description": "Subprocess and terminal provider over the shared POSIX SSH helper", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -36,7 +39,7 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 88686829b6..f3e7df2405 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { @@ -34,7 +37,7 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@deepseek-ai/dsh-storage": "workspace:*", diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index e3ba433024..7dd2b33e54 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index d86ffdb8db..7c057adebe 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 73b95b98e2..9282eac47f 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 07c723beb8..0ca2b62add 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -69,8 +69,8 @@ en: 75d061c2151624a7 zh: 71e58e020b60ff90 /deepseek-ai-dsh-subagent-acp/model-experience/parent-tool-result-indirectly/what-the-model-sees: - en: 5e4e3efe7517f782 - zh: 0465e164583f6bc3 + en: bacf407400c5a362 + zh: 7c9e3daea29508e9 /deepseek-ai-dsh-subagent-acp/model-experience/parent-tool-result-indirectly/token-effect: en: 4fae54e2ff0c4874 zh: 1574bfe874f66a1f diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 32f3e21df8..dcf644a3b4 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -138,7 +138,7 @@ Independent of the parent request cache. Each ACP child can reuse only prefixes #### What the model sees -Through `dsh-tool-subagent`, the parent model first receives a child id, then a completion notice containing final or partial assistant text, the stop reason, and safe diagnostic. The parent Session independently retains the external task identity and complete terminal result. Intermediate messages and tool traffic do not enter the parent Session. +Through `dsh-tool-subagent`, the parent model first receives the child's name, then a completion notice containing final or partial assistant text, the stop reason, and safe diagnostic. The parent Session independently retains the external task identity and complete terminal result. Intermediate messages and tool traffic do not enter the parent Session. #### Token effect diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 288dee7afe..d7b9459809 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -138,7 +138,7 @@ spawn、初始化或新建会话失败会在发布前拒绝,通常先证明 ma #### 模型看到什么 -通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到含最终或部分 assistant 文本、停止原因与安全诊断的完成通知。父会话独立保存外部任务身份和完整终态结果。中间消息与工具通信不进入父会话。 +通过 `dsh-tool-subagent`,父模型先收到子任务名称,随后收到含最终或部分 assistant 文本、停止原因与安全诊断的完成通知。父会话独立保存外部任务身份和完整终态结果。中间消息与工具通信不进入父会话。 #### Token 影响 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 85ff5b0a87..5bf2aa1f6b 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts index 7d32f04fee..79ca510bef 100644 --- a/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts @@ -43,7 +43,7 @@ function settlementText(events: SessionEvent[]): string[] { const notice = notices[0]! if (notice.type !== 'user/message' || notice.data.source.kind !== 'subagent-settled') throw new Error('missing completion notice') expect(results[0]!.data.message.content).toEqual([ - { type: 'text', text: `started subagent ${notice.data.source.senderSessionId}` }, + { type: 'text', text: 'started subagent agent-1' }, ]) return notice.data.content.slice(1).filter(block => block.type === 'text').map(block => block.text) } diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 8ca95c1a0f..9ae58516c8 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -24,8 +24,8 @@ en: fbee5e4ab396c184 zh: dcf94832918d5010 /deepseek-ai-dsh-subagent-claude-code/use-this-package/exposing-the-tool: - en: ebf6ba7fef393ebb - zh: fad30b829e2e4188 + en: e070599b4ff8dd79 + zh: 105e7a4dfee82efe /deepseek-ai-dsh-subagent-claude-code/use-this-package/what-you-get: en: a731eabea54893d5 zh: e370dba101b3a7e8 @@ -66,8 +66,8 @@ en: 4c391bb16e3cd380 zh: ab3847efe35fa6dc /deepseek-ai-dsh-subagent-claude-code/model-experience/parent-scheduling-and-results-indirectly/what-the-model-sees: - en: fd177d7a8aa80ff0 - zh: aa04468315ed2e0b + en: 7e75fac459085412 + zh: 4d8e41b0baada834 /deepseek-ai-dsh-subagent-claude-code/model-experience/parent-scheduling-and-results-indirectly/token-effect: en: 0c436fb0897a6eca zh: 7ec0a953d0232af2 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 72a1b2eb7b..43ec7ba399 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -72,7 +72,7 @@ The bundle inserts `tool-subagent-claude-code` as a Host row, so `subagent_claud maxDepth: provider-managed ``` -The tool returns a child id after accepting the task and sends its result to the parent Agent on completion. Each external activation executes once and accepts no follow-up input or resume. +The tool returns the child's name after accepting the task and sends its result to the parent Agent on completion. Each external activation executes once and accepts no follow-up input or resume. ### What you get @@ -149,7 +149,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own #### What the model sees -Through `dsh-tool-subagent`, the parent model first receives a child id, then the final Claude Code answer or a failure notice with its stop reason and safe diagnostic. Diagnostics contain only fixed stage, category, and observed protocol or process facts. Product reasoning, intermediate messages, tool activity, stderr, usage, product identifiers, commands, paths, and raw protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, the parent model first receives the child's name, then the final Claude Code answer or a failure notice with its stop reason and safe diagnostic. Diagnostics contain only fixed stage, category, and observed protocol or process facts. Product reasoning, intermediate messages, tool activity, stderr, usage, product identifiers, commands, paths, and raw protocol payloads are not copied into the parent Session. #### Token effect diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 1e1a99d29e..059d9fdf46 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -72,7 +72,7 @@ Bundle 将 `tool-subagent-claude-code` 作为 Host 条目插入,因此 `subage maxDepth: provider-managed ``` -工具接受任务后返回 child id;任务完成后向父 agent 发送结果通知。外部 activation 只执行一次,不支持追加输入或恢复对话。 +工具接受任务后返回子任务名称;任务完成后向父 agent 发送结果通知。外部 activation 只执行一次,不支持追加输入或恢复对话。 ### 你会得到什么 @@ -149,7 +149,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 #### 模型看到什么 -通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到 Claude Code 最终答案或带停止原因与安全诊断的失败通知。诊断只包含固定的阶段、类别和已观测的协议或进程事实。产品推理、中间消息、工具活动、stderr、用量、产品标识符、命令、路径与原始协议载荷不会复制到父 Session。 +通过 `dsh-tool-subagent`,父模型先收到子任务名称,随后收到 Claude Code 最终答案或带停止原因与安全诊断的失败通知。诊断只包含固定的阶段、类别和已观测的协议或进程事实。产品推理、中间消息、工具活动、stderr、用量、产品标识符、命令、路径与原始协议载荷不会复制到父 Session。 #### Token 影响 diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 8be23ed17b..3cf5c4206d 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -28,7 +28,10 @@ "locale/*.json", "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { @@ -50,7 +53,7 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "@modelcontextprotocol/sdk": "^1.31.0", - "zod": "4.4.3", + "zod": "4.6.6", "@deepseek-ai/dsh-tool-subagent": "workspace:*" }, "devDependencies": { diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index 26543fb219..6c3234b2f8 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -79,22 +79,22 @@ describe('product-provider public Loader composition', () => { tools: [ { name: 'subagent_codex', - parameterNames: ['cwd', 'description', 'prompt'], + parameterNames: ['cwd', 'description', 'name', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_claude_code', - parameterNames: ['cwd', 'description', 'prompt'], + parameterNames: ['cwd', 'description', 'name', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_claude_primary', - parameterNames: ['cwd', 'description', 'prompt'], + parameterNames: ['cwd', 'description', 'name', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_claude_secondary', - parameterNames: ['cwd', 'description', 'prompt'], + parameterNames: ['cwd', 'description', 'name', 'prompt'], required: ['description', 'prompt'], }, ], diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 4b46c397d5..53af128a5d 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -360,7 +360,7 @@ describe('task admission and package contracts', () => { '@modelcontextprotocol/sdk', '^1.31.0', ) - expect(manifest.dependencies).toHaveProperty('zod', '4.4.3') + expect(manifest.dependencies).toHaveProperty('zod', '4.6.6') expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') const sdkRoot = dirname(fileURLToPath( diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 1b6d354c28..15609babf2 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -24,8 +24,8 @@ en: b8163849d388478e zh: cf74fcaeef62c7ae /deepseek-ai-dsh-subagent-codex/use-this-package/exposing-the-tool: - en: a36399c7ac98724d - zh: d43815e08689da6b + en: b82b4698150bb45e + zh: 40f0d852d317491e /deepseek-ai-dsh-subagent-codex/use-this-package/what-you-get: en: abd75016d4c7f61e zh: 904158c896bd21ce @@ -66,8 +66,8 @@ en: 4c391bb16e3cd380 zh: ab3847efe35fa6dc /deepseek-ai-dsh-subagent-codex/model-experience/parent-scheduling-and-results-indirectly/what-the-model-sees: - en: 9ca324fb0b034a6c - zh: 83ff78167bd31a14 + en: f481d9a59562c629 + zh: eddd8fd082a44807 /deepseek-ai-dsh-subagent-codex/model-experience/parent-scheduling-and-results-indirectly/token-effect: en: f618e05fde7c8b57 zh: 7306c4535b6fa4c0 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 376e6e9fe4..418bff9436 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -70,7 +70,7 @@ The bundle inserts `tool-subagent-codex` as a Host row, so `subagent_codex` is a maxDepth: provider-managed ``` -The tool returns a child id after accepting the task and sends its result to the parent Agent on completion. Each external activation executes once and accepts no follow-up input or resume. +The tool returns the child's name after accepting the task and sends its result to the parent Agent on completion. Each external activation executes once and accepts no follow-up input or resume. ### What you get @@ -147,7 +147,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi #### What the model sees -Through `dsh-tool-subagent`, the parent model first receives a child id, then the selected Codex answer (final, partial, or unphased, in that order of precedence) or a failure notice with its stop reason and safe diagnostic. Diagnostics contain only fixed stage, category, and observed protocol or process facts. Product reasoning, intermediate messages, tool activity, stderr, usage, product identifiers, commands, paths, and raw protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, the parent model first receives the child's name, then the selected Codex answer (final, partial, or unphased, in that order of precedence) or a failure notice with its stop reason and safe diagnostic. Diagnostics contain only fixed stage, category, and observed protocol or process facts. Product reasoning, intermediate messages, tool activity, stderr, usage, product identifiers, commands, paths, and raw protocol payloads are not copied into the parent Session. #### Token effect diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 5e488daaf8..b4419a7d0a 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -70,7 +70,7 @@ Bundle 将 `tool-subagent-codex` 作为 Host 条目插入,因此 `subagent_cod maxDepth: provider-managed ``` -工具接受任务后返回 child id;任务完成后向父 agent 发送结果通知。外部 activation 只执行一次,不支持追加输入或恢复对话。 +工具接受任务后返回子任务名称;任务完成后向父 agent 发送结果通知。外部 activation 只执行一次,不支持追加输入或恢复对话。 ### 你会得到什么 @@ -147,7 +147,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 #### 模型看到什么 -通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到所选的 Codex 答案(按 final、partial、未标阶段的优先次序)或带停止原因与安全诊断的失败通知。诊断只包含固定的阶段、类别和已观测的协议或进程事实。产品推理、中间消息、工具活动、stderr、用量、产品标识符、命令、路径与原始协议载荷不会复制到父 Session。 +通过 `dsh-tool-subagent`,父模型先收到子任务名称,随后收到所选的 Codex 答案(按 final、partial、未标阶段的优先次序)或带停止原因与安全诊断的失败通知。诊断只包含固定的阶段、类别和已观测的协议或进程事实。产品推理、中间消息、工具活动、stderr、用量、产品标识符、命令、路径与原始协议载荷不会复制到父 Session。 #### Token 影响 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index e1f0da6b97..6604ad9373 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -28,7 +28,10 @@ "locale/*.json", "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "dsh": { diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index a9972381ae..e77ad6cd73 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -69,17 +69,17 @@ describe('Codex provider public Loader composition', () => { tools: [ { name: 'subagent_codex', - parameterNames: ['cwd', 'description', 'prompt'], + parameterNames: ['cwd', 'description', 'name', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_codex_primary', - parameterNames: ['cwd', 'description', 'prompt'], + parameterNames: ['cwd', 'description', 'name', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_codex_secondary', - parameterNames: ['cwd', 'description', 'prompt'], + parameterNames: ['cwd', 'description', 'name', 'prompt'], required: ['description', 'prompt'], }, ], diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index b5e6b036c3..2157b5fe7d 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -69,8 +69,8 @@ en: 75d061c2151624a7 zh: 71e58e020b60ff90 /deepseek-ai-dsh-subagent-dsh-sdk/model-experience/parent-tool-result-indirectly/what-the-model-sees: - en: 5e4e3efe7517f782 - zh: 0465e164583f6bc3 + en: bacf407400c5a362 + zh: 7c9e3daea29508e9 /deepseek-ai-dsh-subagent-dsh-sdk/model-experience/parent-tool-result-indirectly/token-effect: en: 4fae54e2ff0c4874 zh: 1574bfe874f66a1f diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 080b49be6f..a0ccfe8faf 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -151,7 +151,7 @@ Independent of the parent request cache. Each SDK child can reuse only prefixes #### What the model sees -Through `dsh-tool-subagent`, the parent model first receives a child id, then a completion notice containing final or partial assistant text, the stop reason, and safe diagnostic. The parent Session independently retains the external task identity and complete terminal result. Intermediate messages and tool traffic do not enter the parent Session. +Through `dsh-tool-subagent`, the parent model first receives the child's name, then a completion notice containing final or partial assistant text, the stop reason, and safe diagnostic. The parent Session independently retains the external task identity and complete terminal result. Intermediate messages and tool traffic do not enter the parent Session. #### Token effect diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index ebab95c57f..b719ba9a8e 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -151,7 +151,7 @@ kind: "package-reference" #### 模型看到什么 -通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到含最终或部分 assistant 文本、停止原因与安全诊断的完成通知。父会话独立保存外部任务身份和完整终态结果。中间消息与工具通信不进入父会话。 +通过 `dsh-tool-subagent`,父模型先收到子任务名称,随后收到含最终或部分 assistant 文本、停止原因与安全诊断的完成通知。父会话独立保存外部任务身份和完整终态结果。中间消息与工具通信不进入父会话。 #### Token 影响 diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 4e10f67d38..00e9aa57ca 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts b/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts index b3eb73801c..b6d6e3d03f 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts @@ -47,7 +47,7 @@ function settlementText(events: SessionEvent[]): string[] { const notice = notices[0]! if (notice.type !== 'user/message' || notice.data.source.kind !== 'subagent-settled') throw new Error('missing completion notice') expect(results[0]!.data.message.content).toEqual([ - { type: 'text', text: `started subagent ${notice.data.source.senderSessionId}` }, + { type: 'text', text: 'started subagent agent-1' }, ]) return notice.data.content.slice(1).filter(block => block.type === 'text').map(block => block.text) } diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index a7a3c2e9d0..f33da46d9b 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-process/README.i18n.yaml @@ -27,8 +27,8 @@ en: d6058c358890c64c zh: 93f8cc2b60975d42 /deepseek-ai-dsh-subagent-fork-in-process/use-this-package/what-a-fork-delegation-does: - en: 461063deda821a1b - zh: 18d2d41d64884f22 + en: 69aa80db5f3aff0c + zh: 9b3b77ea42a6f29c /deepseek-ai-dsh-subagent-fork-in-process/understand-the-implementation: en: 6a0fe4b9cc780aaa zh: 50cd69ceb8296827 @@ -42,8 +42,8 @@ en: cca92ec964b4314b zh: edd8ecd4b59dbe8f /deepseek-ai-dsh-subagent-fork-in-process/understand-the-implementation/lifecycle-binding: - en: 8dad0155ed1b32d2 - zh: f4d82d5ede03c4d7 + en: b4dee8f57a537f0a + zh: 0c759154a69de3ba /deepseek-ai-dsh-subagent-fork-in-process/further-exploration: en: 29ea6e0669b665a8 zh: 46d435e79d356898 @@ -66,8 +66,8 @@ en: 75d061c2151624a7 zh: 71e58e020b60ff90 /deepseek-ai-dsh-subagent-fork-in-process/model-experience/parent-tool-result-indirectly/what-the-model-sees: - en: 54d10ba4da6cdece - zh: bb9cb4d685098483 + en: 7452223ab8b13d47 + zh: f315ec4db035c652 /deepseek-ai-dsh-subagent-fork-in-process/model-experience/parent-tool-result-indirectly/token-effect: en: 8c81bab3c5d6a38a zh: 44c798432a4bfdfb diff --git a/packages/subagent/subagent-fork-in-process/README.md b/packages/subagent/subagent-fork-in-process/README.md index aad91df474..556653b007 100644 --- a/packages/subagent/subagent-fork-in-process/README.md +++ b/packages/subagent/subagent-fork-in-process/README.md @@ -55,7 +55,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What a fork delegation does -One tool call creates a background child and returns its session id immediately. The child works in its own session; the parent receives its final answer in a completion notice. The child can also send messages through `send_message`. A rejected start leaves no published child. Programmatic callers such as workflows can await the activation result directly. +One tool call creates a background child and returns its name immediately. The child works in its own session; the parent receives its final answer in a completion notice. The child can also send messages through `send_message`. A rejected start leaves no published child. Programmatic callers such as workflows can await the activation result directly. ----- @@ -83,7 +83,7 @@ This backend supplies the balanced completed-turn prefix of the parent log as th ### Lifecycle binding -The delegation tool creates a background activation and returns a stable child session id. Parent and child share the same messaging tool definitions and ordering; the parent id and return guidance follow inherited history in the initial task. Workflows await results with caller delivery and send no completion notice to the parent. +The delegation tool creates a background activation and returns the child's name. Parent and child share the same messaging tool definitions and ordering; the parent's name in identity runtime context and the return guidance in the initial task both follow inherited history. Workflows await results with caller delivery and send no completion notice to the parent. @@ -123,7 +123,7 @@ The child may reuse the inherited byte-identical prefix under the same provider #### What the model sees -Through `dsh-tool-subagent`, the parent first receives the child session id, then a completion notice with its final answer. Child-authored messages arrive through `send_message`; the inherited prefix and internal work remain in the child session. +Through `dsh-tool-subagent`, the parent first receives the child's name, then a completion notice with its final answer. Child-authored messages arrive through `send_message`; the inherited prefix and internal work remain in the child session. #### Token effect diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index 6d8d7ac2c7..7d232a1efb 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 一次 fork 委派会做什么 -一次工具调用创建后台子 agent 并立即返回子会话 ID。子级在自己的会话中工作,父级通过完成通知收到其最终答案。子级也可以通过 `send_message` 发送消息。被拒绝的启动不会留下已发布的子 agent;工作流等程序调用方可以直接等待 activation 的结果。 +一次工具调用创建后台子 agent 并立即返回其名称。子级在自己的会话中工作,父级通过完成通知收到其最终答案。子级也可以通过 `send_message` 发送消息。被拒绝的启动不会留下已发布的子 agent;工作流等程序调用方可以直接等待 activation 的结果。 ----- @@ -83,7 +83,7 @@ kind: "package-reference" ### 生命周期绑定 -委派工具以后台 activation 创建子级,并返回稳定的子会话 ID。父级与子级共享相同的消息工具定义及顺序;父级 ID 与返回指导位于继承历史之后的初始任务中。工作流以调用方收集模式等待结果,不向父级发送完成通知。 +委派工具以后台 activation 创建子级,并返回子级名称。父级与子级共享相同的消息工具定义及顺序;身份运行时上下文中的父级名称和初始任务中的返回指导都位于继承历史之后。工作流以调用方收集模式等待结果,不向父级发送完成通知。 @@ -123,7 +123,7 @@ fork 会把保留的已完成历史复制到子 agent 的请求中,子 agent #### 模型看到什么 -父级通过 `dsh-tool-subagent` 先收到子会话 ID,随后收到包含最终答案的完成通知。子级自行编写的消息通过 `send_message` 到达;继承的前缀与内部工作保留在子会话中。 +父级通过 `dsh-tool-subagent` 先收到子级名称,随后收到包含最终答案的完成通知。子级自行编写的消息通过 `send_message` 到达;继承的前缀与内部工作保留在子会话中。 #### Token 影响 diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 8ae4be6b13..44522d6c8e 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml index 0ee0a2191a..cd2ac2ed30 100644 --- a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml @@ -24,8 +24,8 @@ en: 3f53b0d5f4037171 zh: e0f2ac931cfbef95 /deepseek-ai-dsh-subagent-spawn-in-process/use-this-package/what-a-delegation-does: - en: 0c90404ed53cf451 - zh: 161e38b7d7c10182 + en: 3990b19cb5c58c39 + zh: 3c974311a437a84f /deepseek-ai-dsh-subagent-spawn-in-process/understand-the-implementation: en: 7f34637751939818 zh: 2937553168e1aee5 @@ -63,8 +63,8 @@ en: 75d061c2151624a7 zh: 71e58e020b60ff90 /deepseek-ai-dsh-subagent-spawn-in-process/model-experience/parent-tool-result-indirectly/what-the-model-sees: - en: caf11edef2516b8c - zh: 25bcb16cceaad06d + en: b540f44830cec85f + zh: 7a02564ac5fbc9cf /deepseek-ai-dsh-subagent-spawn-in-process/model-experience/parent-tool-result-indirectly/token-effect: en: ff8045aa0f466c2a zh: e38fc5c6af2ce076 diff --git a/packages/subagent/subagent-spawn-in-process/README.md b/packages/subagent/subagent-spawn-in-process/README.md index bd5a08b848..c81fb63901 100644 --- a/packages/subagent/subagent-spawn-in-process/README.md +++ b/packages/subagent/subagent-spawn-in-process/README.md @@ -51,7 +51,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What a delegation does -One tool call creates a background child and returns its session id immediately. The child works in its own session; the parent receives its final answer in a completion notice. The child can also send messages through `send_message`. A rejected start leaves no published child. Programmatic callers such as workflows can await the activation result directly. +One tool call creates a background child and returns its name immediately. The child works in its own session; the parent receives its final answer in a completion notice. The child can also send messages through `send_message`. A rejected start leaves no published child. Programmatic callers such as workflows can await the activation result directly. An explicit `agentPreset` selects the currently registered preset independently; omission joins the parent's active revision. Only spawn supports this choice. Persona and tools belong in the [preset definition](../../preset/agent-preset-registry/README.md). Cold resume follows the shared [child composition rules](../subagent/README.md#restoring-child-composition). @@ -120,7 +120,7 @@ The child's request cache is independent of the parent's. Child history grows ap #### What the model sees -Through `dsh-tool-subagent`, the parent first receives the child session id, then a completion notice with its final answer. Child-authored messages arrive through `send_message`. Internal child tool calls remain in the child session. +Through `dsh-tool-subagent`, the parent first receives the child's name, then a completion notice with its final answer. Child-authored messages arrive through `send_message`. Internal child tool calls remain in the child session. #### Token effect diff --git a/packages/subagent/subagent-spawn-in-process/README.zh.md b/packages/subagent/subagent-spawn-in-process/README.zh.md index 5133d18837..b60b16cab2 100644 --- a/packages/subagent/subagent-spawn-in-process/README.zh.md +++ b/packages/subagent/subagent-spawn-in-process/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 一次委派会做什么 -一次工具调用创建后台子 agent 并立即返回子会话 ID。子级在自己的会话中工作,父级通过完成通知收到其最终答案。子级也可以通过 `send_message` 发送消息。被拒绝的启动不会留下已发布的子 agent;工作流等程序调用方可以直接等待 activation 的结果。 +一次工具调用创建后台子 agent 并立即返回其名称。子级在自己的会话中工作,父级通过完成通知收到其最终答案。子级也可以通过 `send_message` 发送消息。被拒绝的启动不会留下已发布的子 agent;工作流等程序调用方可以直接等待 activation 的结果。 显式 `agentPreset` 独立选择当前注册的 preset;省略时加入父方的活跃代际。只有 spawn 支持此选择。persona 与工具放在 [preset 定义](../../preset/agent-preset-registry/README.zh.md)中。冷恢复遵守共享的[子级组装规则](../subagent/README.zh.md#restoring-child-composition)。 @@ -120,7 +120,7 @@ kind: "package-reference" #### 模型看到什么 -通过 `dsh-tool-subagent`,父级先收到子会话 ID,随后收到包含最终答案的完成通知。子级自行编写的消息通过 `send_message` 到达。子级内部工具调用保留在子会话中。 +通过 `dsh-tool-subagent`,父级先收到子级名称,随后收到包含最终答案的完成通知。子级自行编写的消息通过 `send_message` 到达。子级内部工具调用保留在子会话中。 #### Token 影响 diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 9657a0ea5c..b82531af90 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -25,7 +25,10 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" ], "license": "MIT", "peerDependencies": { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 106360686c..4ff564cb25 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: 025605eddae02478 - zh: 5659cba4340735d8 + en: c0718955687ff245 + zh: eb2d3cdbe92e0fe2 /deepseek-ai-dsh-subagent/use-this-package/delegation-settings: en: 86c982223fab1a3a zh: 3d220d8ed84eab9a @@ -27,8 +27,11 @@ en: 4b40bd380bea81ce zh: 896a584190981168 /deepseek-ai-dsh-subagent/use-this-package/local-and-external-children: - en: 191ef1dabe2fce7c - zh: f799fdcbb5c5a171 + en: d6eef66bc1d293fe + zh: e4754d38212c0b2e +/deepseek-ai-dsh-subagent/use-this-package/agent-names: + en: e46a141f9126c45f + zh: 15f6011c36baa20c /deepseek-ai-dsh-subagent/use-this-package/child-presets: en: a740e5e9bdd661d9 zh: 712a5458fc8b3615 @@ -51,8 +54,8 @@ en: 61cddba23f4e7d1c zh: 052c8331a747403f /deepseek-ai-dsh-subagent/understand-the-implementation/activation-settlement: - en: fc8938de925b1a97 - zh: b50e1f948d60b93c + en: 9543250098033347 + zh: 3721af3e31409a32 /deepseek-ai-dsh-subagent/understand-the-implementation/restoring-child-composition: en: 843f9f4319ca2ae3 zh: 218bf355816d4ff6 @@ -69,11 +72,11 @@ en: f7cec22ba9d3fb4f zh: 67b1d40f9b136f71 /deepseek-ai-dsh-subagent/model-experience/settlement-notice/what-the-model-sees: - en: d5e6377fabfee1ca - zh: f5c442401264a60d + en: 45d8a22bad10fba2 + zh: 0ebd7f4570150713 /deepseek-ai-dsh-subagent/model-experience/settlement-notice/token-effect: - en: f98ad834e8ce8950 - zh: cf509805dd934094 + en: 435e801179ee02c8 + zh: 72551f95c5ee7bca /deepseek-ai-dsh-subagent/model-experience/settlement-notice/kv-cache-effect: en: d6b1d618083da79b zh: 9a28942b7d53c382 @@ -108,8 +111,8 @@ en: 59e848fca82a46fb zh: fcd6a8a2bb58609f /deepseek-ai-dsh-subagent/known-limitations-and-deferred-work: - en: 746c75e25114ab8b - zh: 168d577f1fcc4281 + en: fb3ae9c914da17ff + zh: 18d2fe7caadc9c70 /deepseek-ai-dsh-subagent/known-limitations-and-deferred-work/dev-note: en: 25cc0909348666bb zh: 95517f12de100049 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4803cff356..b8fd869989 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -42,7 +42,7 @@ Mount the service with `@deepseek-ai/dsh-session-projection`, a provider, and th toolName: subagent ``` -The tool returns a child session id immediately. The parent receives a completion notice when the background activation settles. Mounting the service alone enables no delegation. +The tool returns the child's name immediately. The parent receives a completion notice when the background activation settles. Mounting the service alone enables no delegation. ### Delegation settings @@ -58,10 +58,17 @@ At capacity, creation or cold resume rejects with `ACTIVATION_LIMIT_REACHED` (br ### Local and external children -`startActivation({ provider, label, prompt, parent, signal, delivery })` returns `{ childId, messageId?, result, dispose }`. Supplying `childId` selects a local backend and guarantees `messageId`. Local children accept later messages and can cold-resume; external children expose one execution and reject continuation. `agentOptions` overrides supported child model settings. `delivery: 'parent'` supplies the model-facing completion notice; `delivery: 'caller'` lets workflows await the result without a parent notice or initial return guidance. Local parent delivery requires persistence; caller delivery can use an ephemeral local session. Cold resume requires persistence and Session query. +`startActivation({ provider, label, name?, prompt, parent, signal, delivery })` returns `{ childId, name, messageId?, result, dispose }`. Supplying `childId` selects a local backend and guarantees `messageId`. Local children accept later messages and can cold-resume; external children expose one execution and reject continuation. `agentOptions` overrides supported child model settings. `delivery: 'parent'` supplies the model-facing completion notice; `delivery: 'caller'` lets workflows await the result without a parent notice or initial return guidance. Local parent delivery requires persistence; caller delivery can use an ephemeral local session. Cold resume requires persistence and Session query. `rootOf(agent)` resolves a managed local Agent through its existing Activation and checks the exact live child and root instances. Intermediate creators can leave without changing that root; same-id replacements cannot inherit membership. Ordinary live Agents resolve to themselves, while unmanaged delegated Agents are rejected. The query does not activate Sessions. + +### Agent names + +Every cataloged child has a name that is unique among its creator's children. Pass `name` to choose it: 1–32 lower-case letters, digits, or hyphens, starting with a letter, other than `lead`. Omission selects the first free `agent-N` (`agent-1`, `agent-2`, …) among the names in the creator's catalog and the names held by creations still starting. A name that a sibling already holds rejects with `DUPLICATE_AGENT_NAME`, and a name that breaks the rule rejects with `INVALID_AGENT_NAME`; both reject before startup, and the runtime never substitutes another name. The creator reserves the name synchronously at admission and releases the reservation when the catalog entry commits or startup fails. Caller-delivered external runs enter no catalog: they reject a requested name with `UNSUPPORTED_CAPABILITY`, and their activation name is their Session id. + +The root is always `lead`. A descendant's full name is the slash-separated path of names from the root without a `lead/` prefix, such as `reviewer` or `reviewer/checker`; sibling-unique segments make full names unique in the tree. `SubagentActivation.name` and the `path` field of `listDescendants()` rows carry the full name. A child whose catalog entry has no name, and every descendant below it, uses its Session id as its name. `namesOf(agent)` reports a live member's name and its creator's name. `resolveName(caller, name, signal)` returns the Session id for `lead` or a full name, reading only the catalogs along the path. Any other text that names no member is returned unchanged as a Session id, because a single token can be one; a slash-separated address never is, so an unmatched path fails with `AGENT_NAME_NOT_FOUND`. Resolution grants no authority: messaging, interruption, and task assignment authorize the resolved Session id. + ### Child presets In-process spawn accepts an independent `agentPreset`; fork and external backends reject this option. Without a selection, a local child joins the parent's current preset revision. An explicit id mounts the currently available revision, even when that id matches the parent's. Configure model choices through [`dsh-tool-subagent`](../tool-subagent/README.md#selecting-a-child-preset) and persona and tools in the [preset composition](../../preset/agent-preset-registry/README.md). Cold resume preserves persona and tool restrictions recorded in historical child descriptors. @@ -123,7 +130,7 @@ Settlement attribution records the exact execution stop reason without altering A local Agent failure without a subsequently committed `turn/end` settles as `error` independently of its descendants. Unclaimed input cannot keep that failed activation resident. A later committed turn ending supersedes the live failure and supplies the recovered outcome. -Successful local creation appends a `subagent/catalog` fact to the parent Session regardless of result delivery. External creation appends a catalog fact only with `delivery: 'parent'`; caller-owned external executions retain their caller's membership records. External entries use `mode: 'external'` to identify children without a local Session. Catalog entries record membership once at creation and carry no execution status. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection, so completed local workflow children remain discoverable and can be opened or continued. Catalog payload v0 records local modes; v1 also accepts unknown mode; v2 records external executions. Readers support all three versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; local creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. +Successful local creation appends a `subagent/catalog` fact to the parent Session regardless of result delivery. External creation appends a catalog fact only with `delivery: 'parent'`; caller-owned external executions retain their caller's membership records. External entries use `mode: 'external'` to identify children without a local Session. Catalog entries record membership once at creation and carry no execution status. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection, so completed local workflow children remain discoverable and can be opened or continued. Catalog payload v0 records local modes; v1 also accepts unknown mode; v2 records external executions; v3 adds the child's name to continuable and external entries. Local and parent-delivered external creation write v3, and readers support all four versions. The `subagentCatalog` projection state version remains 6: unnamed checkpoints are reused, and suffix replay appends named facts without replaying the checkpoint prefix or backfilling historical names. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. ### Restoring child composition @@ -167,11 +174,11 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -Parent delivery sends a user-role status notice to the resident direct parent in the same tree, including an idle parent. It does not restore an offline parent. The notice contains the child’s nonempty final text blocks. When the child's last action before that text was a message to its parent, with no input claimed and no tool call logged after it, the notice replaces them with `It sent you a message just before finishing, so its closing message is omitted.`; the parent recovers omitted closing text only by messaging the child again. External notices also state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Every parent notice includes available structured output as `Structured result: ` and the provider’s safe diagnostic as a separate text block. Empty closing text is reported as `It left no closing message.` Caller delivery emits no notice; SDK lifecycle notifications retain complete child output when cleanup succeeds. +Parent delivery sends a user-role status notice to the resident direct parent in the same tree, including an idle parent. It does not restore an offline parent. Its opening line names the child by its full name, such as `Background subagent reviewer finished and will do no further work unless you send it more.` The notice contains the child’s nonempty final text blocks. When the child's last action before that text was a message to its parent, with no input claimed and no tool call logged after it, the notice replaces them with `It sent you a message just before finishing, so its closing message is omitted.`; the parent recovers omitted closing text only by messaging the child again. External notices also state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Every parent notice includes available structured output as `Structured result: ` and the provider’s safe diagnostic as a separate text block. Empty closing text is reported as `It left no closing message.` Caller delivery emits no notice; SDK lifecycle notifications retain complete child output when cleanup succeeds. #### Token effect -One notice per settled activation using parent delivery. Caller delivery adds no completion message; its consumer owns result presentation. +One notice per settled activation using parent delivery; a default name such as `agent-1` is shorter than the 36-character Session id that names a child without a recorded name. Caller delivery adds no completion message; its consumer owns result presentation. #### KV Cache effect @@ -225,8 +232,9 @@ Prefix-stable within a child: the statement never changes during the child's lif These limits define when the seam is a poor fit or needs special operational care. They are current package constraints, not a general delegation comparison or a task backlog. - **Descendant reads are sequential** — each reachable catalog, including one-shot children, takes one observation. A cold Session without a valid prepared observation requires a full-log read; large cold trees can accumulate storage latency. +- **Names are permanent** — a name stays taken among its creator's children for as long as the catalog entry exists; names cannot be changed or reused, and no migration names children whose catalog entries predate names. - **External children execute once** — ACP, DSH SDK, Codex, and Claude Code have no local child Session and accept no follow-up input. Their parent-owned records remain discoverable after disposal. -- **Messaging stays within one tree** — targets use Session ids and must be the root or continuable members. Only the restored target consumes activation capacity. Browser prompts retain their separate human Queue-or-Steer control path. +- **Messaging stays within one tree** — targets must be the root or continuable members. Only the restored target consumes activation capacity. Browser prompts retain their separate human Queue-or-Steer control path. - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the Agent becomes idle stays queued until another waking send. - **Pending injected context retains an Activation** — settlement conservatively treats every Inbox occurrence as unfinished. Context parked after the Agent becomes idle keeps the child and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it. Host completion joins active work, pending child creation, and resource cleanup, but does not wait for a subtree containing only idle parked inboxes; it neither completes those activation results nor discards their messages. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index c73f19f5ba..e0f8f35bda 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -42,7 +42,7 @@ kind: "package-reference" toolName: subagent ``` -工具立即返回子会话 ID,后台 activation 结算后父级收到完成通知。仅挂载服务本身不会启用委派。 +工具立即返回子级的名称,后台 activation 结算后父级收到完成通知。仅挂载服务本身不会启用委派。 ### 委派设置 @@ -58,10 +58,17 @@ kind: "package-reference" ### 本地与外部子级 -`startActivation({ provider, label, prompt, parent, signal, delivery })` 返回 `{ childId, messageId?, result, dispose }`。提供 `childId` 会选择本地后端,并保证返回 `messageId`。本地子级接受后续消息并支持冷恢复;外部子级只暴露一次执行,拒绝继续输入。`agentOptions` 覆盖后端支持的子级模型设置。`delivery: 'parent'` 提供面向模型的完成通知;`delivery: 'caller'` 让工作流等待结果,不发送父级通知,也不附加初始返回指导。本地父级投递需要持久化,调用方投递可以使用临时本地会话;冷恢复需要持久化与 Session 查询服务。 +`startActivation({ provider, label, name?, prompt, parent, signal, delivery })` 返回 `{ childId, name, messageId?, result, dispose }`。提供 `childId` 会选择本地后端,并保证返回 `messageId`。本地子级接受后续消息并支持冷恢复;外部子级只暴露一次执行,拒绝继续输入。`agentOptions` 覆盖后端支持的子级模型设置。`delivery: 'parent'` 提供面向模型的完成通知;`delivery: 'caller'` 让工作流等待结果,不发送父级通知,也不附加初始返回指导。本地父级投递需要持久化,调用方投递可以使用临时本地会话;冷恢复需要持久化与 Session 查询服务。 `rootOf(agent)` 通过托管本地 Agent 已有的 Activation 解析树根,并校验子级和树根的确切存活实例。中间创建者退出不改变该树根;同 id 替代实例不能继承成员身份。普通存活 Agent 返回自身,非托管的委派 Agent 会被拒绝。查询不会激活 Session。 + +### Agent 名称 + +每个进入目录的子级都有一个名称,在其创建者的子级之间唯一。传入 `name` 可选择名称:1–32 个小写字母、数字或连字符,以字母开头,且不能是 `lead`。省略时,运行时在创建者目录中的名称以及仍在启动的创建所持有的名称之外,选择第一个空闲的 `agent-N`(`agent-1`、`agent-2`……)。请求同级已持有的名称会以 `DUPLICATE_AGENT_NAME` 拒绝,不符合规则的名称会以 `INVALID_AGENT_NAME` 拒绝;两者都在启动前拒绝,运行时绝不替换为其他名称。创建者在准入时同步预留名称,并在目录条目提交或启动失败时释放预留。调用方投递的外部执行不进入目录:请求名称时以 `UNSUPPORTED_CAPABILITY` 拒绝,其 activation 名称为其 Session id。 + +根的名称始终为 `lead`。后代的完整名称是从根开始、以斜杠分隔的名称路径,不带 `lead/` 前缀,例如 `reviewer` 或 `reviewer/checker`;同级唯一的各段使完整名称在整棵树中唯一。`SubagentActivation.name` 和 `listDescendants()` 行的 `path` 字段携带完整名称。目录条目没有名称的子级及其下所有后代使用 Session id 作为名称。`namesOf(agent)` 报告在线成员及其创建者的名称。`resolveName(caller, name, signal)` 为 `lead` 或完整名称返回 Session id,只读取路径上的目录。树中不存在的其他文本原样作为 Session id 返回,因为单个词元可能就是 Session id;斜杠路径不可能是 Session id,因此指向无成员的路径以 `AGENT_NAME_NOT_FOUND` 失败。解析不授予权限:消息、中断和任务分配会对解析出的 Session id 进行授权。 + ### 子级 preset 进程内 spawn 接受独立的 `agentPreset`;fork 与外部后端拒绝此选项。省略选择时,本地子级加入父方当前的 preset 代际。显式 id 挂载当前可用代际,即使该 id 与父方相同。通过 [`dsh-tool-subagent`](../tool-subagent/README.zh.md#selecting-a-child-preset) 配置模型可选项,并在 [preset 组装](../../preset/agent-preset-registry/README.zh.md)中配置 persona 与工具。冷恢复保留历史子级 descriptor 记录的 persona 和工具限制。 @@ -123,7 +130,7 @@ kind: "package-reference" 本地 Agent 发生失败且后续没有成功提交 `turn/end` 时,会独立于后代以 `error` 结算。未认领的输入不会使该失败 activation 一直驻留。后续成功提交的轮次结束记录会取代实时失败,提供恢复后的结果。 -本地创建成功后,无论结果交付给谁,都会向父 Session 追加 `subagent/catalog` 事实。外部创建仅在使用 `delivery: 'parent'` 时追加目录事实;调用方拥有的外部执行保留调用方自己的成员记录。外部条目以 `mode: 'external'` 标记没有本地 Session 的子级。目录仅在创建时记录一次成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影,因此已完成的本地工作流子级仍可被发现、打开或继续。Catalog 载荷 v0 记录本地模式,v1 还接受未知模式,v2 记录外部执行,读取器支持三版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;本地创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 +本地创建成功后,无论结果交付给谁,都会向父 Session 追加 `subagent/catalog` 事实。外部创建仅在使用 `delivery: 'parent'` 时追加目录事实;调用方拥有的外部执行保留调用方自己的成员记录。外部条目以 `mode: 'external'` 标记没有本地 Session 的子级。目录仅在创建时记录一次成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影,因此已完成的本地工作流子级仍可被发现、打开或继续。Catalog 载荷 v0 记录本地模式,v1 还接受未知模式,v2 记录外部执行,v3 为可继续和外部条目增加子级名称。本地创建和父级投递的外部创建写入 v3,读取器支持全部四个版本。`subagentCatalog` 投影状态版本保持为 6:复用无名检查点,后缀回放追加具名事实,不重新回放检查点前缀,也不回填历史名称。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 ### 恢复子级组装 @@ -167,11 +174,11 @@ kind: "package-reference" #### 模型看到什么 -父级投递向同树中驻留的直接父级发送 user-role 状态通知,包括空闲父级。它不会恢复离线父级。通知包含子级最终输出中的非空文本块。若子级在该文本之前的最后一个动作是向父级发送消息,且此后既未领取输入也未记录任何工具调用,通知会以 `It sent you a message just before finishing, so its closing message is omitted.` 替代这些文本块;父级只有再次向子级发送消息才能取回被省略的收尾文本。外部通知还说明不支持后续消息。推理及其他非文本块被排除。每条父级通知都会以 `Structured result: ` 附带可用的结构化输出,并以独立文本块附带提供方安全诊断。收尾文本为空时,通知使用 `It left no closing message.`。调用方投递不发送通知;清理成功时 SDK 生命周期通知保留完整子级输出。 +父级投递向同树中驻留的直接父级发送 user-role 状态通知,包括空闲父级。它不会恢复离线父级。首行使用子级的完整名称,例如 `Background subagent reviewer finished and will do no further work unless you send it more.`。通知包含子级最终输出中的非空文本块。若子级在该文本之前的最后一个动作是向父级发送消息,且此后既未领取输入也未记录任何工具调用,通知会以 `It sent you a message just before finishing, so its closing message is omitted.` 替代这些文本块;父级只有再次向子级发送消息才能取回被省略的收尾文本。外部通知还说明不支持后续消息。推理及其他非文本块被排除。每条父级通知都会以 `Structured result: ` 附带可用的结构化输出,并以独立文本块附带提供方安全诊断。收尾文本为空时,通知使用 `It left no closing message.`。调用方投递不发送通知;清理成功时 SDK 生命周期通知保留完整子级输出。 #### Token 影响 -父级投递为每个结算的 activation 添加一条通知。调用方投递不添加完成消息,由其消费者负责展示结果。 +父级投递为每个结算的 activation 添加一条通知;`agent-1` 等默认名称比没有记录名称的子级所用的 36 字符 Session id 更短。调用方投递不添加完成消息,由其消费者负责展示结果。 #### KV Cache 影响 @@ -225,8 +232,9 @@ You are a delegated subagent: your permission scope was fixed when you were star 这些限制说明该 seam 何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用委派对比或任务积压。 - **后代读取串行执行**——每个可达目录(包括一次性子级)都需要一次观察。冷 Session 缺少有效的 prepared 观察时需要读取完整日志;大型冷会话树可能累积存储延迟。 +- **名称不可变更**——只要目录条目存在,名称就在创建者的子级之间保持占用;名称不能修改或复用,也没有迁移为早于名称机制的目录条目补充名称。 - **外部子级执行一次**——ACP、DSH SDK、Codex 与 Claude Code 没有本地子 Session,也不接受后续输入。释放后仍可发现父级拥有的执行记录。 -- **消息限定在一棵树内**——目标使用 Session id,必须是根或可继续成员。仅恢复的目标占用激活容量。浏览器提示词保留独立的人类 Queue 或 Steer 控制路径。 +- **消息限定在一棵树内**——目标必须是根或可继续成员。仅恢复的目标占用激活容量。浏览器提示词保留独立的人类 Queue 或 Steer 控制路径。 - **取消收敛期间存在唤醒缺口**——中断信号发出后、Agent 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。 - **待处理的注入上下文会保留 Activation**——settlement 会保守地把每个 Inbox occurrence 都视为未完成。Agent 进入 idle 后停放的上下文会让 child 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。宿主完成检查等待活动工作、待创建子级与资源清理,但不会等待仅包含空闲停放 Inbox 的子树;它既不结算这些 activation 的结果,也不丢弃其消息。 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 1c599b29a6..6bff46b9ee 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.2.2-alpha.3", + "version": "0.2.2-rc.1", "publishConfig": { "access": "public" }, @@ -39,10 +39,14 @@ "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "MIT", "dependencies": { @@ -50,7 +54,7 @@ "@deepseek-ai/dsh-chunked-list": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "zod": "4.6.6" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/subagent/subagent/src/activation.ts b/packages/subagent/subagent/src/activation.ts index fb9fd56b51..b6d7ce6aff 100644 --- a/packages/subagent/subagent/src/activation.ts +++ b/packages/subagent/subagent/src/activation.ts @@ -51,6 +51,8 @@ interface ActivationBase { readonly root: Agent /** Verified creation ancestors, from subagent tree root to direct creator. */ readonly ancestors: readonly SessionId[] + /** Slash-separated names from the root; undefined when this child or a creator below the root has no name. */ + readonly path: string | undefined readonly delivery: 'parent' | 'caller' readonly result: PromiseWithResolvers /** Published synchronously before teardown; subsequent callers share this transaction. */ diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 416625c594..836f725d0a 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -15,16 +15,20 @@ import type { SessionLogOffset, } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { isAgentName } from './names.ts' import type { SubagentCatalogEntry } from './projection-types.ts' -/** Catalog payload version emitted by local child creation. */ -export const SUBAGENT_CATALOG_VERSION = 0 +/** Catalog payload version emitted by local and external child creation. */ +export const SUBAGENT_CATALOG_VERSION = 3 type KnownCatalogMode = | { readonly mode: 'one-shot'; readonly label?: string } | { readonly mode: 'continuable'; readonly label: string } -/** Parent catalog v0 records local modes, v1 adds unknown children, and v2 records external executions. */ +/** + * Parent catalog v0 records local modes, v1 adds unknown children, v2 records + * external executions, and v3 adds each child's name, unique among its siblings. + */ export type SubagentCatalogEvent = & { readonly childId: SessionId @@ -33,6 +37,10 @@ export type SubagentCatalogEvent = | ({ readonly version: 0 } & KnownCatalogMode) | ({ readonly version: 1 } & (KnownCatalogMode | { readonly mode: 'unknown'; readonly label?: string })) | { readonly version: 2; readonly mode: 'external'; readonly label?: string } + | ({ readonly version: 3; readonly name: string } & ( + | { readonly mode: 'continuable'; readonly label: string } + | { readonly mode: 'external'; readonly label?: string } + )) ) declare module '@deepseek-ai/dsh-session/types' { @@ -68,11 +76,16 @@ const continuableCatalogSchema = z.object({ }).strict() const externalCatalogSchema = oneShotCatalogSchema.extend({ version: z.literal(2), mode: z.literal('external') }) const unknownCatalogSchema = oneShotCatalogSchema.extend({ version: z.literal(1), mode: z.literal('unknown') }) +const nameSchema = z.string().refine(isAgentName, 'invalid agent name') +const namedContinuableCatalogSchema = continuableCatalogSchema.extend({ version: z.literal(3), name: nameSchema }) +const namedExternalCatalogSchema = externalCatalogSchema.extend({ version: z.literal(3), name: nameSchema }) const eventDataSchema = z.union([ oneShotCatalogSchema, externalCatalogSchema, continuableCatalogSchema, unknownCatalogSchema, + namedContinuableCatalogSchema, + namedExternalCatalogSchema, ]) as z.ZodType const viewSchema = z.array(z.union([ oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ @@ -82,10 +95,12 @@ const viewSchema = z.array(z.union([ externalCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, createdAt: externalCatalogSchema.shape.childCreatedAt, + name: nameSchema.optional(), }), continuableCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, createdAt: continuableCatalogSchema.shape.childCreatedAt, + name: nameSchema.optional(), }), unknownCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, @@ -111,7 +126,7 @@ function subagentCatalogEntries(state: SubagentCatalogState): SubagentCatalogEnt return entries } -/** Parent-owned direct-child catalog projection; invalid own facts reject restoration. */ +/** Parent-owned catalog; version-6 checkpoints retain unnamed facts, and invalid own facts reject restoration. */ export const subagentCatalogProjectionDefinition = { key: 'subagentCatalog', stateSchema, @@ -129,11 +144,13 @@ export const subagentCatalogProjectionDefinition = { * @param parent - durable direct parent receiving the discovery fact. * @param child - established child's immutable Session metadata. * @param descriptor - mode-discriminated creation label frozen with the child. + * @param name - the child's name, reserved unique among the parent's children. */ export function establishCatalogChild( parent: Session, child: SessionHeader, descriptor: { readonly mode: 'continuable'; readonly label: string }, + name: string, ): void { parent.append('subagent/catalog', { version: SUBAGENT_CATALOG_VERSION, @@ -141,24 +158,37 @@ export function establishCatalogChild( childCreatedAt: child.createdAt, mode: descriptor.mode, label: descriptor.label, + name, }) } -/** Catalog payload version emitted by external execution creation. */ -const EXTERNAL_SUBAGENT_CATALOG_VERSION = 2 - /** * Record an external execution without a local child Session. * @param parent - durable direct parent receiving the discovery fact. * @param childId - identity returned by the external provider. * @param label - display label assigned by the caller. + * @param name - the child's name, reserved unique among the parent's children. */ -export function establishExternalCatalogChild(parent: Session, childId: SessionId, label: string): void { +export function establishExternalCatalogChild(parent: Session, childId: SessionId, label: string, name: string): void { parent.append('subagent/catalog', { - version: EXTERNAL_SUBAGENT_CATALOG_VERSION, + version: SUBAGENT_CATALOG_VERSION, childId, childCreatedAt: Date.now(), mode: 'external', label, + name, }) } + +/** + * Collect the names recorded in one parent's catalog. + * @param entries - the parent's current catalog rows. + * @returns names of every named child; historical entries contribute none. + */ +export function catalogNames(entries: readonly SubagentCatalogEntry[]): Set { + const names = new Set() + for (const entry of entries) { + if ('name' in entry) names.add(entry.name) + } + return names +} diff --git a/packages/subagent/subagent/src/continuation-messages.ts b/packages/subagent/subagent/src/continuation-messages.ts index 8b97212a59..00d507c854 100644 --- a/packages/subagent/subagent/src/continuation-messages.ts +++ b/packages/subagent/subagent/src/continuation-messages.ts @@ -57,16 +57,18 @@ function agentMessageSource(sender: Agent): AgentMessageSource { /** * Build the model-visible and durable representation of one Agent message. * @param sender - exact live Agent that authored the message. + * @param senderName - the sender's model-facing name in its subagent tree. * @param content - model-visible message blocks supplied by the sender. * @returns the durable user-message representation delivered to the recipient. */ export function createAgentMessage( sender: Agent, + senderName: string, content: ContentBlock[], ): ReturnType { return createUserMessage({ content: [ - { type: 'text' as const, text: `Agent ${sender.id} sent a message: ` }, + { type: 'text' as const, text: `Agent ${senderName} sent a message: ` }, ...content, ], source: agentMessageSource(sender), @@ -96,13 +98,13 @@ export function withResultDeliveryGuidance( /** * One line telling a parent that a background child is finished and why, in * the parent's own task vocabulary. - * @param childId - the durable child the parent knows by id. + * @param childName - the model-facing name the parent knows the child by. * @param stopReason - how the child's last ordinary turn ended. * @param continuable - whether the child accepts a later message. * @returns the model-facing opening line of the settlement notice. */ -function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason'], continuable: boolean): string { - const subject = `Background subagent ${childId}` +function settlementSummary(childName: string, stopReason: SubagentResult['stopReason'], continuable: boolean): string { + const subject = `Background subagent ${childName}` switch (stopReason) { case 'completed': return continuable @@ -128,7 +130,8 @@ function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopR /** * Build the runtime-owned settlement notice from the child's nonempty closing text. - * @param childId - durable child session id named in the notice. + * @param childId - durable child session id recorded as the sender. + * @param childName - model-facing child name used in the notice text. * @param terminal - recorded terminal state for the settled Activation. * @param continuable - whether the child can accept another task. * @param omitClosingText - replace the closing text when the child's last action @@ -137,11 +140,12 @@ function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopR */ export function createSettlementMessage( childId: SessionId, + childName: string, terminal: SubagentResult, continuable = true, omitClosingText = false, ): ReturnType { - const summary = settlementSummary(childId, terminal.stopReason, continuable) + const summary = settlementSummary(childName, terminal.stopReason, continuable) // Parent providers receive this notice as a user message and may reject // nontext assistant blocks. Keep this conversion local so SDK/UI consumers // retain the complete child output. diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index d79e12bb1d..bb89264e91 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -33,6 +33,7 @@ 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' +import { brandString } from '@deepseek-ai/dsh-brand' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -55,6 +56,7 @@ import type { SubagentActivation, SubagentActivationSpec, SubagentInterruptAuthority, + SubagentMemberNames, SubagentProvider, SubagentRunEndInfo, SubagentRunInfo, @@ -66,16 +68,18 @@ import { assertSubagentMaxDepth } from './depth.ts' import { createLifecycleEmitter } from './lifecycle.ts' import type { LifecycleEmitter } from './lifecycle.ts' import SubagentManager from './manager.ts' -import { listDescendants as listSubagentDescendants } from './list-children.ts' +import { listDescendants as listSubagentDescendants, resolveAgentPath } from './list-children.ts' import type { SubagentDescendantListEntry } from './list-children.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' import { subagentCatalogProjectionDefinition } from './catalog.ts' +import { ROOT_AGENT_NAME } from './names.ts' export type {} from './catalog.ts' export type {} from './projection.ts' export * from './out-of-process.ts' export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' +export { ROOT_AGENT_NAME } from './names.ts' export type { ContinuableCreateRequest, ContinuableCreateSpec, @@ -83,6 +87,7 @@ export type { SubagentActivation, SubagentActivationSpec, SubagentInterruptAuthority, + SubagentMemberNames, SubagentProvider, SubagentResult, SubagentRun, @@ -208,13 +213,14 @@ export class SubagentRuntime extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.ctx - await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + const catalogProjection = await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) await ctx.sessionProjections.register(subagentTimingProjectionDefinition) const identityProjection = await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) if (ctx.fiber.uid === null) return ctx.inject(['agents'], (childCtx: Context) => { const manager = new SubagentManager(childCtx, { identityOf: session => identityProjection.stateOf(session).identity, + catalogOf: session => subagentCatalogProjectionDefinition.wire.view(catalogProjection.stateOf(session)), emitLifecycle: this.emitLifecycle, }, () => this.config.maxActiveSubagents.get()) this.manager = manager @@ -295,6 +301,47 @@ export class SubagentRuntime extends TypertRemoteService { return this.requireManager().rootOf(caller) } + /** + * Report the model-facing names of a live tree member and its direct creator. + * The root is `lead`; a descendant is the slash-separated path of names from + * the root, or its Session id when it or a creator below the root has no name. + * @param agent - exact live root or resident tree member. + * @returns the member's name and, for a descendant, its creator's name. + * @throws when the caller or its runtime owner is unavailable. + */ + namesOf(agent: Agent): SubagentMemberNames { + return this.requireManager().namesOf(agent) + } + + /** + * Resolve a model-supplied agent reference within the caller's tree. `lead` + * selects the root and a slash-separated path is looked up through the + * catalogs along it. Any other text that is not a name in the tree is + * returned as a Session id, because a single token can be one; a + * slash-separated address never is, so an unmatched path fails instead of + * reaching a member lookup as an unknown id. Resolution grants no authority: + * messaging, interruption, and assignment authorize the result. + * @param caller - exact live Agent whose tree root anchors the lookup. + * @param name - `lead`, a full name from the root, or a Session id. + * @param signal - cancellation forwarded to each catalog read. + * @returns the referenced Session id. + * @throws {@link SubagentError} `AGENT_NAME_NOT_FOUND` when a path names no member; + * when the caller's root is unavailable; or when a catalog along the path cannot be read. + */ + async resolveName(caller: Agent, name: string, signal: AbortSignal): Promise { + const root = this.rootOf(caller) + if (name === ROOT_AGENT_NAME) return root.id + const resolved = await resolveAgentPath(this.ctx, root.id, name, signal) + if (resolved !== undefined) return resolved + if (name.includes('/')) { + throw new SubagentError( + `agent name ${JSON.stringify(name)} was not found in this subagent tree; use list_agents to see the names`, + 'AGENT_NAME_NOT_FOUND', + ) + } + return brandString(name) + } + /** * Join progressing subagent tree work for its root. Idle members with parked input * stay resident without delaying host completion. Ordinary members return false. diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 0a737b98d7..41e0b88915 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentListEntry } from './control-types.ts' import { SubagentError } from './error.ts' +import { isAgentName, joinAgentPath } from './names.ts' import type { SubagentCatalogEntry } from './projection-types.ts' export type { SubagentListEntry } from './control-types.ts' @@ -18,6 +19,8 @@ export type SubagentDescendantListEntry = SubagentListEntry & { readonly parentId: SessionId /** Edge distance from the requested root; direct children are `1`. */ readonly depth: number + /** Slash-separated names from the requested root; absent when this child or an ancestor below the root has no name. */ + readonly path?: string } /** @@ -85,15 +88,18 @@ export async function listDescendants( assertListingNotCancelled(signal) return children } - const stack = (await readChildren(rootSessionId)) - .map(entry => ({ entry, parentId: rootSessionId, depth: 1 })) + type Position = { entry: SubagentCatalogEntry; parentId: SessionId; depth: number; parentPath: string | undefined } + const stack: Position[] = (await readChildren(rootSessionId)) + .map(entry => ({ entry, parentId: rootSessionId, depth: 1, parentPath: '' })) .reverse() const visited = new Set([rootSessionId]) const result: SubagentDescendantListEntry[] = [] for (let position = stack.pop(); position !== undefined; position = stack.pop()) { - const { entry, parentId, depth } = position + const { entry, parentId, depth, parentPath } = position if (visited.has(entry.id)) continue visited.add(entry.id) + const path = joinAgentPath(parentPath, 'name' in entry ? entry.name : undefined) + const at = { parentId, depth, ...path === undefined ? {} : { path } } let children: SubagentCatalogEntry[] try { children = entry.mode === 'external' ? [] : await readChildren(entry.id) @@ -101,30 +107,59 @@ export async function listDescendants( if (error instanceof SubagentError) throw error const code = error instanceof Error && 'code' in error ? error.code : undefined result.push({ - kind: 'diagnostic', id: entry.id, parentId, depth, + kind: 'diagnostic', id: entry.id, ...at, reason: code === 'SESSION_QUERY_CORRUPT_SESSION' || code === 'SESSION_QUERY_SOURCE_CONFLICT' ? 'corrupt' : 'unavailable', }) continue } if (entry.mode === 'unknown') { - result.push({ kind: 'diagnostic', id: entry.id, parentId, depth, reason: 'unsupported' }) + result.push({ kind: 'diagnostic', id: entry.id, ...at, reason: 'unsupported' }) } else { const { createdAt: _createdAt, ...identity } = entry result.push({ - ...identity, kind: 'child', parentId, depth, + ...identity, kind: 'child', ...at, activity: sessions.get(entry.id) === undefined ? 'inactive' : 'running', hasChildren: children.length > 0, }) } // Catalog event order defines siblings; the stack visits the first one next. for (const child of [...children].reverse()) { - stack.push({ entry: child, parentId: entry.id, depth: depth + 1 }) + stack.push({ entry: child, parentId: entry.id, depth: depth + 1, parentPath: path }) } } return result } +/** + * Resolve a slash-separated name by reading only the catalogs along its path. + * @param ctx - context carrying the Session query service. + * @param rootSessionId - tree root whose catalog holds the first name. + * @param name - candidate full name relative to the root. + * @param signal - cancellation forwarded to each catalog read. + * @returns the named member's Session id, or undefined when the text is not a name in this tree. + * @throws {@link SubagentError} when a catalog dependency is unavailable; Session read failures propagate. + */ +export async function resolveAgentPath( + ctx: Context, + rootSessionId: SessionId, + name: string, + signal: AbortSignal, +): Promise { + const segments = name.split('/') + if (!segments.every(isAgentName)) return undefined + let current = rootSessionId + for (const [index, segment] of segments.entries()) { + const children = await listChildren(ctx, current, signal) + const child = children.find(entry => 'name' in entry && entry.name === segment) + if (child === undefined) return undefined + // External and unknown children own no local Session, so they are the last segment too. + if (index < segments.length - 1 && child.mode !== 'continuable' && child.mode !== 'one-shot') return undefined + current = child.id + } + return current +} + /** Stop the complete traversal when its caller cancels. */ function assertListingNotCancelled(signal: AbortSignal | undefined): void { if (signal?.aborted) { diff --git a/packages/subagent/subagent/src/manager.ts b/packages/subagent/subagent/src/manager.ts index dbd9d3a382..db6f04b019 100644 --- a/packages/subagent/subagent/src/manager.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -30,14 +30,16 @@ import { import { installSubagentArchiveAdmission } from './archive-admission.ts' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' -import { establishCatalogChild, establishExternalCatalogChild } from './catalog.ts' +import { catalogNames, establishCatalogChild, establishExternalCatalogChild } from './catalog.ts' import { SubagentError } from './error.ts' import type { LifecycleEmitter } from './lifecycle.ts' -import type { SubagentIdentityProjection } from './projection-types.ts' +import { ROOT_AGENT_NAME, SiblingNameReservations, agentAddress, joinAgentPath, parentAgentPath } from './names.ts' +import type { SiblingNameReservation } from './names.ts' +import type { SubagentCatalogEntry, SubagentIdentityProjection } from './projection-types.ts' import { SubagentRunId } from './types.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentInterruptAuthority, - SubagentActivationSpec, SubagentActivation, SubagentStartRequest, SubagentRun, + SubagentActivationSpec, SubagentActivation, SubagentMemberNames, SubagentStartRequest, SubagentRun, SubagentSendMessageOptions, SubagentResult, SubagentRunInfo, ResolvedSubagentStartRequest, } from './types.ts' import { attachStructuredRuntime } from './structured.ts' @@ -61,6 +63,8 @@ type ChildDeliveryOptions = { interface SubagentHost { /** Read the current descriptor identity through the service-owned projection. */ identityOf(session: Session): SubagentIdentityProjection | undefined + /** Read a live Session's direct-child catalog through the service-owned projection. */ + catalogOf(session: Session): readonly SubagentCatalogEntry[] /** Publish lifecycle events through the owning service's scoped dispatcher. */ readonly emitLifecycle: LifecycleEmitter } @@ -70,6 +74,8 @@ interface MaterializeBase { provider: string root: Agent ancestors: readonly SessionId[] + /** Slash-separated names from the root, undefined when any link is unnamed. */ + path: string | undefined signal: AbortSignal delivery?: 'parent' | 'caller' | undefined } @@ -143,6 +149,8 @@ export class SubagentManager { private readonly ownerCtx: Context /** Exact subagent tree roots whose host teardown has closed admission. */ private readonly closingRoots = new Set() + /** Sibling names held by creations that have not committed their catalog entry. */ + private readonly names = new SiblingNameReservations() private draining = false /** @@ -298,6 +306,24 @@ export class SubagentManager { const parent = spec.parent this.assertAdmitting(parent) const ancestry = this.ancestryForChild(parent) + const reservation = this.reserveName(parent, spec.name) + const path = joinAgentPath(this.pathOf(parent, ancestry.root), reservation.name) + try { + return await this.startLocalNamed(spec, prepare, ancestry, reservation, path) + } finally { + reservation.release() + } + } + + /** Create one local child under a name reserved before any await. */ + private startLocalNamed( + spec: SubagentActivationSpec, + prepare: (request: ContinuableCreateRequest) => Promise, + ancestry: Pick, + reservation: SiblingNameReservation, + path: string | undefined, + ): Promise { + const parent = spec.parent return this.trackStartup(ancestry.root, spec.signal, async (signal) => { const persistence = spec.delivery === 'caller' ? this.ctx.get('sessionPersistence') : this.requirePersistence() const childId = spec.childId ?? brandString(randomUUID()) @@ -352,6 +378,7 @@ export class SubagentManager { childId, provider: spec.provider, ...ancestry, + path, create: { parent, cwd, @@ -379,7 +406,7 @@ export class SubagentManager { ancestry.root, () => { this.assertAdmitting(parent) - establishCatalogChild(parent.session, childHeader, descriptor) + establishCatalogChild(parent.session, childHeader, descriptor, reservation.name) }, ) }) @@ -397,10 +424,32 @@ export class SubagentManager { spec: SubagentActivationSpec, start: (request: ResolvedSubagentStartRequest) => Promise, ): Promise { - const { provider, delivery, childId: _childId, ...request } = spec - const parent = request.parent + const { delivery, name: requestedName, parent } = spec this.assertAdmitting(parent) const ancestry = this.ancestryForChild(parent) + if (delivery === 'caller' && requestedName !== undefined) { + throw new SubagentError('agent names require a cataloged child; caller-delivered external runs are not cataloged', 'UNSUPPORTED_CAPABILITY') + } + // Only parent-delivered external runs enter the catalog, so only they hold a name. + const reservation = delivery === 'parent' ? this.reserveName(parent, requestedName) : undefined + const path = joinAgentPath(this.pathOf(parent, ancestry.root), reservation?.name) + try { + return await this.startExternalNamed(spec, start, ancestry, reservation, path) + } finally { + reservation?.release() + } + } + + /** Start one external execution, cataloging parent-delivered runs under their reserved name. */ + private startExternalNamed( + spec: SubagentActivationSpec, + start: (request: ResolvedSubagentStartRequest) => Promise, + ancestry: Pick, + reservation: SiblingNameReservation | undefined, + path: string | undefined, + ): Promise { + const { provider, delivery, childId: _childId, name: _name, ...request } = spec + const parent = request.parent return this.trackStartup(ancestry.root, spec.signal, async (signal) => { let activation: Activation | undefined try { @@ -409,6 +458,7 @@ export class SubagentManager { kind: 'external', provider, ...ancestry, + path, signal, delivery, start: signal => start({ @@ -417,8 +467,8 @@ export class SubagentManager { }) this.assertAdmitting(parent) signal.throwIfAborted() - if (delivery === 'parent') { - establishExternalCatalogChild(parent.session, activation.childId, spec.label) + if (reservation !== undefined) { + establishExternalCatalogChild(parent.session, activation.childId, spec.label, reservation.name) } this.accept(activation) return this.receipt(activation) @@ -472,6 +522,34 @@ export class SubagentManager { return { root, ancestors: [...ancestors, creator.id] } } + /** Claim a sibling name against the creator's committed catalog and in-flight siblings. */ + private reserveName(parent: Agent, requested: string | undefined): SiblingNameReservation { + return this.names.reserve(parent, catalogNames(this.host.catalogOf(parent.session)), requested) + } + + /** Path of an exact live tree member: `''` for the root, undefined for unnamed history. */ + private pathOf(member: Agent, root: Agent): string | undefined { + return member === root ? '' : this.resident.get(member.id)?.path + } + + /** + * Report the model-facing names of a live member and its direct creator. + * @param agent - exact live root or resident tree member. + * @returns `lead` for the root, otherwise the member's and creator's names or Session ids. + * @throws when the caller has no active runtime owner. + */ + namesOf(agent: Agent): SubagentMemberNames { + const root = this.rootOf(agent) + if (root === agent) return { self: ROOT_AGENT_NAME } + // rootOf returns another root only for the caller's own resident Activation. + const activation = this.resident.get(agent.id) as Activation + const parentId = activation.ancestors.at(-1) as SessionId + const parentPath = activation.path === undefined + ? parentId === root.id ? '' : this.resident.get(parentId)?.path + : parentAgentPath(activation.path) + return { self: agentAddress(activation.path, agent.id), parent: agentAddress(parentPath, parentId) } + } + /** * Deliver to another agent in the subagent tree, restoring only the target when absent. * @param sender - exact live root or resident continuable Agent. @@ -485,7 +563,7 @@ export class SubagentManager { const root = this.rootOf(sender) if (sender.id === targetId) throw new SubagentError('send_message requires another agent in the subagent tree', 'UNAUTHORIZED') this.assertAdmitting(sender) - const message = createAgentMessage(sender, content) + const message = createAgentMessage(sender, agentAddress(this.pathOf(sender, root), sender.id), content) const activation = this.resident.get(sender.id) return this.deliverToMember(root, targetId, [...message.content], { signal: options.signal, delivery: 'steer', source: message.source, @@ -499,10 +577,14 @@ export class SubagentManager { } /** Resolve the live owner through verified creation edges without activating ancestors. */ - private async resolveCreationAncestry(source: SessionObservation, signal: AbortSignal, root?: Agent): Promise> { + private async resolveCreationAncestry( + source: SessionObservation, signal: AbortSignal, root?: Agent, + ): Promise> { const ancestors: SessionId[] = [] + const names: (string | undefined)[] = [] const seen = new Set([source.header.id]) let { header, projections, inheritedEventCount } = source + const pathFromRoot = (): string | undefined => names.reduce(joinAgentPath, '') while (true) { signal.throwIfAborted() const parentId = header.parentSession @@ -511,11 +593,13 @@ export class SubagentManager { if (seen.has(parentId)) throw new SubagentError('cyclic subagent creation ancestry', 'UNAUTHORIZED') seen.add(parentId) using parent = await this.observeContinuation(parentId, signal) - if (!parent.projections?.values.subagentCatalog?.some(entry => entry.id === header.id)) { + const entry = parent.projections?.values.subagentCatalog?.find(entry => entry.id === header.id) + if (entry === undefined) { throw new SubagentError('subagent is absent from its creator catalog', 'UNAUTHORIZED') } ancestors.unshift(parentId) - if (parentId === root?.id) return { root, ancestors } + names.unshift('name' in entry ? entry.name : undefined) + if (parentId === root?.id) return { root, ancestors, path: pathFromRoot() } ;({ header, projections, inheritedEventCount } = parent) } if (root === undefined && ancestors.length > 0 && header.origin !== 'subagent') { @@ -523,7 +607,7 @@ export class SubagentManager { if (owner === undefined || this.rootOf(owner) !== owner) { throw new SubagentError('subagent tree root is not live', 'CONTINUATION_UNAVAILABLE') } - return { root: owner, ancestors } + return { root: owner, ancestors, path: pathFromRoot() } } throw new SubagentError(`agent "${source.header.id}" is not a member of this subagent tree`, 'UNAUTHORIZED') } @@ -793,6 +877,7 @@ export class SubagentManager { ...resource, root, ancestors: inputs.ancestors, + path: inputs.path, delivery: inputs.delivery ?? 'parent', result: Promise.withResolvers(), closing: undefined, @@ -1028,7 +1113,8 @@ export class SubagentManager { const parent = this.ctx.agents.get(parentId) if (parent === undefined || this.rootOf(parent) !== activation.root) return const message = createSettlementMessage( - activation.childId, terminal, activation.kind === 'local', activation.kind === 'local' && activation.omitClosingText, + activation.childId, agentAddress(activation.path, activation.childId), terminal, + activation.kind === 'local', activation.kind === 'local' && activation.omitClosingText, ) if (this.closingTeardownFor(parent) !== undefined) { parent.inject(message) @@ -1059,6 +1145,7 @@ export class SubagentManager { private receipt(activation: Activation): SubagentActivation { return { childId: activation.childId, + name: agentAddress(activation.path, activation.childId), result: activation.result.promise, dispose: () => this.dispose(activation), } @@ -1125,7 +1212,7 @@ export class SubagentManager { 'NOT_RESUMABLE', ) } - const { ancestors } = await this.resolveCreationAncestry(source, signal, root) + const { ancestors, path } = await this.resolveCreationAncestry(source, signal, root) let activation: Activation try { const projectedPreset = source.projections?.values.agentPreset @@ -1139,6 +1226,7 @@ export class SubagentManager { provider: descriptor.provider, root, ancestors, + path, agentOptions: { ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, diff --git a/packages/subagent/subagent/src/names.ts b/packages/subagent/subagent/src/names.ts new file mode 100644 index 0000000000..24d45fd8d0 --- /dev/null +++ b/packages/subagent/subagent/src/names.ts @@ -0,0 +1,113 @@ +/** + * Model-facing agent names. Each child's name is unique among its creator's + * children and is recorded in the creator's catalog; a member's full name is + * the slash-separated path of names from the root, whose own name is `lead`. + * + * @module @deepseek-ai/dsh-subagent/names + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SubagentError } from './error.ts' + +/** The root agent's fixed name; no child may take it. */ +export const ROOT_AGENT_NAME = 'lead' + +/** One lower-case segment of at most 32 characters, so a 36-character UUID never matches. */ +const NAME_PATTERN = /^[a-z][a-z0-9-]{0,31}$/u + +/** + * Whether a string is a valid child name. + * @param value - candidate name segment. + * @returns true for 1–32 lower-case letters, digits, or hyphens starting with a letter, other than `lead`. + */ +export function isAgentName(value: string): boolean { + return NAME_PATTERN.test(value) && value !== ROOT_AGENT_NAME +} + +/** + * Append a child's name to its creator's path. The root's path is the empty string. + * @param parentPath - the creator's path, or undefined when an ancestor below the root has no name. + * @param name - the child's catalog name, absent on historical entries. + * @returns the child's path, or undefined when any link is unnamed. + */ +export function joinAgentPath(parentPath: string | undefined, name: string | undefined): string | undefined { + if (parentPath === undefined || name === undefined) return undefined + return parentPath === '' ? name : `${parentPath}/${name}` +} + +/** + * Select the model-facing name of one tree member. + * @param path - the member's path; `''` for the root and undefined for unnamed history. + * @param id - the member's Session id, used when no complete path exists. + * @returns `lead`, the full path, or the Session id. + */ +export function agentAddress(path: string | undefined, id: string): string { + if (path === '') return ROOT_AGENT_NAME + return path ?? id +} + +/** + * Remove the last name from a descendant path. + * @param path - a non-empty descendant path. + * @returns the creator's path; `''` when the creator is the root. + */ +export function parentAgentPath(path: string): string { + const separator = path.lastIndexOf('/') + return separator < 0 ? '' : path.slice(0, separator) +} + +/** A sibling name held from admission until the creator's catalog commits or startup fails. */ +export interface SiblingNameReservation { + readonly name: string + /** Return the name to the pool; repeated calls are harmless. */ + release(): void +} + +/** Names claimed by in-flight creations, keyed by the exact live creator. */ +export class SiblingNameReservations { + private readonly pending = new Map>() + + /** + * Claim a name synchronously before startup awaits, so concurrent siblings cannot share it. + * @param parent - exact live creator whose catalog receives the name. + * @param committed - names already recorded in the creator's catalog. + * @param requested - requested name; omission selects the first free `agent-N`. + * @returns the claimed name and its release. + * @throws {@link SubagentError} `INVALID_AGENT_NAME` or `DUPLICATE_AGENT_NAME`. + */ + reserve(parent: Agent, committed: ReadonlySet, requested?: string): SiblingNameReservation { + const pending = this.pending.get(parent) ?? new Set() + const taken = (name: string): boolean => committed.has(name) || pending.has(name) + let name: string + if (requested === undefined) { + let ordinal = 1 + while (taken(`agent-${ordinal}`)) ordinal++ + name = `agent-${ordinal}` + } else { + if (!isAgentName(requested)) { + throw new SubagentError( + `agent name ${JSON.stringify(requested)} is invalid; use 1-32 lower-case letters, digits, or hyphens ` + + `starting with a letter, other than "${ROOT_AGENT_NAME}"`, + 'INVALID_AGENT_NAME', + ) + } + if (taken(requested)) { + throw new SubagentError( + `agent name ${JSON.stringify(requested)} is already used by another subagent you created; choose another name`, + 'DUPLICATE_AGENT_NAME', + ) + } + name = requested + } + pending.add(name) + this.pending.set(parent, pending) + return { + name, + release: () => { + pending.delete(name) + // A later reservation for the same creator replaces the set; a stale release must not drop it. + if (pending.size === 0 && this.pending.get(parent) === pending) this.pending.delete(parent) + }, + } + } +} diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index c30f739475..36ed39b8e1 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -6,7 +6,7 @@ import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' -/** Known child modes and their creation labels, shared by catalog views. */ +/** Known child modes with their creation labels and names, shared by catalog views. */ export type KnownSubagentCatalogFields = | { /** A terminal one-shot child. */ @@ -19,12 +19,16 @@ export type KnownSubagentCatalogFields = readonly mode: 'external' /** Optional durable creation label from the parent catalog. */ readonly label?: string + /** Name unique among the parent's children; absent on historical entries. */ + readonly name?: string } | { /** A resumable conversation. */ readonly mode: 'continuable' /** Durable creation label from the parent catalog. */ readonly label: string + /** Name unique among the parent's children; absent on historical entries. */ + readonly name?: string } /** One current direct-child discovery row materialized from parent facts. */ diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 5c4ab63e7b..1786ed8e2e 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -31,6 +31,12 @@ export interface SubagentActivationSpec extends Omit } +/** Model-facing names of one live tree member and its direct creator. */ +export interface SubagentMemberNames { + /** `lead` for the root, the slash-separated path for a named descendant, otherwise the Session id. */ + readonly self: string + /** The direct creator's name by the same rule; absent for the root. */ + readonly parent?: string +} + /** * Authority under which one interrupt request is admitted. `user` is a trusted * host operation; Remote callers need permission on the target Session before diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index ac576639fa..e0d12d620c 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -11,6 +11,7 @@ import { } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { + catalogNames, establishExternalCatalogChild, subagentCatalogProjectionDefinition, } from '../src/catalog.ts' @@ -58,12 +59,12 @@ describe('subagent catalog projection', () => { await ctx.plugin(SessionStore) const parent = ctx.sessions.create(header.id) const before = Date.now() - establishExternalCatalogChild(parent, SessionId('external'), 'External work') + establishExternalCatalogChild(parent, SessionId('external'), 'External work', 'agent-1') const events = parent.snapshotEvents() expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ type: 'subagent/catalog', - data: { version: 2, childId: 'external', mode: 'external', label: 'External work' }, + data: { version: 3, childId: 'external', mode: 'external', label: 'External work', name: 'agent-1' }, }) const entries = subagentCatalogProjectionDefinition.wire.view(fold(events)) expect(entries).toHaveLength(1) @@ -191,6 +192,13 @@ describe('subagent catalog projection', () => { { version: 9, childId: 'child', childCreatedAt: 0, mode: 'one-shot' }, { version: 0, childId: 'child', childCreatedAt: 0, mode: 'continuable' }, { version: 0, childId: 'child', childCreatedAt: -1, mode: 'one-shot' }, + { version: 3, childId: 'child', childCreatedAt: 0, mode: 'continuable', label: 'child' }, + { version: 3, childId: 'child', childCreatedAt: 0, mode: 'continuable', label: 'child', name: 'lead' }, + { version: 3, childId: 'child', childCreatedAt: 0, mode: 'continuable', label: 'child', name: 'Reviewer' }, + { version: 3, childId: 'child', childCreatedAt: 0, mode: 'one-shot', name: 'reviewer' }, + { version: 3, childId: 'child', childCreatedAt: 0, mode: 'unknown', name: 'reviewer' }, + { version: 2, childId: 'child', childCreatedAt: 0, mode: 'external', name: 'reviewer' }, + { version: 0, childId: 'child', childCreatedAt: 0, mode: 'continuable', label: 'child', name: 'reviewer' }, ])('refuses to restore a catalog containing an invalid fact: %j', async (data) => { const ctx = new Context() try { @@ -232,3 +240,68 @@ describe('external catalog membership', () => { expect(subagentCatalogProjectionDefinition.wire.viewSchema.safeParse([{ id: 'external', createdAt: 1, mode: 'external', external: true }]).success).toBe(false) }) }) + +describe('named catalog membership', () => { + it('reuses version-6 unnamed checkpoints and folds only the named suffix', async () => { + const ctx = new Context() + try { + await ctx.plugin(SessionProjectionRegistry) + const apply = vi.fn(subagentCatalogProjectionDefinition.apply) + await ctx.sessionProjections.register({ ...subagentCatalogProjectionDefinition, apply }) + const historical: SessionEvent<'subagent/catalog'>[] = [ + fact(0, 'legacy-local', 1, { mode: 'continuable', label: 'Legacy' }), + { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, + data: { version: 1, childId: SessionId('legacy-unknown'), childCreatedAt: 2, mode: 'unknown' } }, + { type: 'subagent/catalog', seq: SessionSeq(2), time: 0, + data: { version: 2, childId: SessionId('legacy-external'), childCreatedAt: 3, mode: 'external' } }, + ] + const suffix: SessionEvent<'subagent/catalog'>[] = [ + { type: 'subagent/catalog', seq: SessionSeq(3), time: 0, + data: { version: 3, childId: SessionId('reviewer'), childCreatedAt: 4, + mode: 'continuable', label: 'Review', name: 'reviewer' } }, + { type: 'subagent/catalog', seq: SessionSeq(4), time: 0, + data: { version: 3, childId: SessionId('auditor'), childCreatedAt: 5, mode: 'external', name: 'auditor' } }, + ] + const checkpoint = { subagentCatalog: { ver: 6, seq: SessionSeq(2), val: { + inheritedEventCount: 0, head: { values: historical.map(event => event.data) }, + } } } + expect(ctx.sessionProjections.restoreFloor(checkpoint)).toBe(2) + expect(ctx.sessionProjections.viewCheckpoint(checkpoint).subagentCatalog).toEqual([ + { id: 'legacy-local', createdAt: 1, mode: 'continuable', label: 'Legacy' }, + { id: 'legacy-unknown', createdAt: 2, mode: 'unknown' }, + { id: 'legacy-external', createdAt: 3, mode: 'external' }, + ]) + expect(apply).not.toHaveBeenCalled() + const restored = ctx.sessionProjections.restore(checkpoint, suffix, SessionLogOffset(3), header, SessionLogOffset(0)) + expect(apply.mock.calls.map(([, event]) => event)).toEqual(suffix) + const full = ctx.sessionProjections.restore({}, [...historical, ...suffix], SessionLogOffset(0), header, SessionLogOffset(0)) + expect(restored).toEqual(full) + expect(restored.checkpoint['subagentCatalog']!.ver).toBe(6) + expect(restored.snapshot.values.subagentCatalog?.map(entry => 'name' in entry ? entry.name : undefined)) + .toEqual([undefined, undefined, undefined, 'reviewer', 'auditor']) + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores version-3 names beside unnamed historical children', () => { + const named: SessionEvent<'subagent/catalog'>[] = [ + { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, + data: { version: 3, childId: SessionId('reviewer'), childCreatedAt: 2, mode: 'continuable', label: 'Review', name: 'reviewer' } }, + { type: 'subagent/catalog', seq: SessionSeq(2), time: 0, + data: { version: 3, childId: SessionId('auditor'), childCreatedAt: 3, mode: 'external', label: 'Audit', name: 'auditor' } }, + ] + const state = fold([fact(0, 'historical', 1, { mode: 'continuable', label: 'Old work' }), ...named]) + const view = subagentCatalogProjectionDefinition.wire.view( + subagentCatalogProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(state))), + ) + expect(view).toEqual([ + { id: SessionId('historical'), createdAt: 1, mode: 'continuable', label: 'Old work' }, + { id: SessionId('reviewer'), createdAt: 2, mode: 'continuable', label: 'Review', name: 'reviewer' }, + { id: SessionId('auditor'), createdAt: 3, mode: 'external', label: 'Audit', name: 'auditor' }, + ]) + expect(subagentCatalogProjectionDefinition.wire.viewSchema.parse(view)).toEqual(view) + expect(subagentCatalogProjectionDefinition.wire.viewSchema.safeParse([{ ...view[1], name: 'Bad name' }]).success).toBe(false) + expect(catalogNames(view)).toEqual(new Set(['reviewer', 'auditor'])) + }) +}) diff --git a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts index eaf74fc60b..7c031ca554 100644 --- a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts @@ -79,7 +79,7 @@ it('continues the parent through default Messages after a reasoning-bearing cont expect(requests.map(request => request.path)).toEqual(['/anthropic/v1/messages', '/anthropic/v1/messages']) const notice = parent.session.deriveMessages().find(message => message.source.kind === 'subagent-settled') const closingContent = [ - { type: 'text', text: `Background subagent ${started.childId} finished and will do no further work unless you send it more.` }, + { type: 'text', text: 'Background subagent agent-1 finished and will do no further work unless you send it more.' }, { type: 'text', text: 'Its closing message:' }, { type: 'text', text: 'child answer' }, ] diff --git a/packages/subagent/subagent/tests/continuation-messages.spec.ts b/packages/subagent/subagent/tests/continuation-messages.spec.ts index 925a251051..8e45bffcff 100644 --- a/packages/subagent/subagent/tests/continuation-messages.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages.spec.ts @@ -4,7 +4,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { createSettlementMessage } from '../src/continuation-messages.ts' const childId = SessionId('settled-child') -const summary = { type: 'text', text: `Background subagent ${childId} finished and will do no further work unless you send it more.` } +const childName = 'reviewer/checker' +const summary = { type: 'text', text: `Background subagent ${childName} finished and will do no further work unless you send it more.` } const reasoning: ContentBlock = { type: 'reasoning', text: 'private child reasoning' } const toolCall: ContentBlock = { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' } @@ -12,7 +13,7 @@ describe('settlement attribution', () => { it.each(['completed', 'aborted', 'max-tokens', 'refusal', 'error'] as const)( 'records the exact %s stop reason without changing model content', (stopReason) => { const terminal = { stopReason, output: [{ type: 'text' as const, text: ' answer\n' }] } - const message = createSettlementMessage(childId, terminal) + const message = createSettlementMessage(childId, childName, terminal) expect(message.source).toMatchObject({ kind: 'subagent-settled', form: 'notice', senderSessionId: childId, stopReason, @@ -41,7 +42,7 @@ describe('continuable settlement content', () => { ['a tool call after the answer', [{ type: 'text', text: 'answer' }, toolCall]], ] satisfies [string, ContentBlock[]][])('reports only the closing text with %s', (_label, output) => { const original = structuredClone(output) - const message = createSettlementMessage(childId, { stopReason: 'completed', output }) + const message = createSettlementMessage(childId, childName, { stopReason: 'completed', output }) expect(message.role).toBe('user') expect(message.content).toEqual([ @@ -57,7 +58,7 @@ describe('continuable settlement content', () => { ['reasoning-only output', [reasoning]], ['empty text', [{ type: 'text', text: '' }]], ] satisfies [string, ContentBlock[]][])('reports no closing message for %s', (_label, output) => { - const message = createSettlementMessage(childId, { stopReason: 'completed', output }) + const message = createSettlementMessage(childId, childName, { stopReason: 'completed', output }) expect(message.content).toEqual([ summary, @@ -68,7 +69,7 @@ describe('continuable settlement content', () => { it('preserves text block order and bytes around omitted reasoning and tool calls', () => { const first: ContentBlock = { type: 'text', text: ' first\n' } const second: ContentBlock = { type: 'text', text: '\n第二段 ' } - const message = createSettlementMessage(childId, { + const message = createSettlementMessage(childId, childName, { stopReason: 'completed', output: [reasoning, first, toolCall, second], }) @@ -83,7 +84,7 @@ describe('continuable settlement content', () => { }) it('includes closing text alongside structured results and diagnostics', () => { - expect(createSettlementMessage(childId, { + expect(createSettlementMessage(childId, childName, { stopReason: 'completed', output: [{ type: 'text', text: 'final answer' }], structured: { answer: 42 }, diagnostic: 'safe diagnostic', }).content).toEqual([ @@ -96,7 +97,7 @@ it('includes closing text alongside structured results and diagnostics', () => { }) it('omits the closing text after the child messaged its parent, keeping structured results and diagnostics', () => { - expect(createSettlementMessage(childId, { + expect(createSettlementMessage(childId, childName, { stopReason: 'completed', output: [{ type: 'text', text: 'final answer, restated' }], structured: { answer: 42 }, diagnostic: 'safe diagnostic', }, true, true).content).toEqual([ diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index e53dd28875..a3cb8d3b93 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -2864,7 +2864,7 @@ describe('continuable adjacent-Agent delivery', () => { senderSessionId: started.childId, }) expect(delivered?.content).toEqual([ - { type: 'text', text: `Agent ${started.childId} sent a message: ` }, + { type: 'text', text: `Agent ${started.name} sent a message: ` }, { type: 'text', text: 'an explicit message' }, ]) @@ -2950,7 +2950,7 @@ describe('continuable settlement delivery', () => { await leaf.result const notice = settlementNotices(creatorAgent).find(item => item.sender === leaf.childId)! expect(notice.text).toBe( - `Background subagent ${leaf.childId} finished and will do no further work unless you send it more.\n` + `Background subagent ${leaf.name} finished and will do no further work unless you send it more.\n` + (target === 'parent' ? 'It sent you a message just before finishing, so its closing message is omitted.' : 'Its closing message:\nleaf answer'), @@ -2972,11 +2972,11 @@ describe('continuable settlement delivery', () => { const notice = settlementNotices(parent)[0]! expect(notice.sender).toBe(started.childId) expect(notice.text).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, + `Background subagent ${started.name} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, ) // The collapsed row states the outcome without the child's content. expect(notice.summary).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.`, + `Background subagent ${started.name} finished and will do no further work unless you send it more.`, ) }) @@ -2998,7 +2998,7 @@ describe('continuable settlement delivery', () => { // message, is left out. await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.\n` + `Background subagent ${started.name} finished and will do no further work unless you send it more.\n` + 'It sent you a message just before finishing, so its closing message is omitted.', ) }) @@ -3028,7 +3028,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, + `Background subagent ${started.name} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, ) }) @@ -3067,7 +3067,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, + `Background subagent ${started.name} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, ) }) @@ -3084,7 +3084,7 @@ describe('continuable settlement delivery', () => { const parentId = /You are a subagent delegated by agent \\"([^\\]+)\\"/.exec(history)![1]! const calls = order.map(name => name === 'noop' ? { id: ToolCallId('n1'), name, arguments: '{}' } - : { id: ToolCallId('s1'), name, arguments: JSON.stringify({ agent_id: parentId, message: 'interim' }) }) + : { id: ToolCallId('s1'), name, arguments: JSON.stringify({ agent: parentId, message: 'interim' }) }) return [ ...calls.flatMap((call, index): StreamChunk[] => [ { type: 'block-start', index, blockType: 'tool-call' }, @@ -3112,7 +3112,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.\n${ending}`, + `Background subagent ${started.name} finished and will do no further work unless you send it more.\n${ending}`, ) }) @@ -3123,7 +3123,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} ran out of room before it finished.\nIts closing message:\nhalf an ans`, + `Background subagent ${started.name} ran out of room before it finished.\nIts closing message:\nhalf an ans`, ) }) @@ -3141,7 +3141,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} declined the task.\nIt left no closing message.`, + `Background subagent ${started.name} declined the task.\nIt left no closing message.`, ) }) @@ -3171,7 +3171,7 @@ describe('continuable settlement delivery', () => { const child = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(child.events, 'second task')).toBe(false) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} failed before it finished.\nIts closing message:\nthe answer`, + `Background subagent ${started.name} failed before it finished.\nIts closing message:\nthe answer`, ) }) @@ -3206,7 +3206,7 @@ describe('continuable settlement delivery', () => { // task had finished. await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.\nIts closing message:\nthe answer`, + `Background subagent ${started.name} was stopped before it finished.\nIts closing message:\nthe answer`, ) }) @@ -3232,7 +3232,7 @@ describe('continuable settlement delivery', () => { // that genuinely finished with no output. await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.\nIt left no closing message.`, + `Background subagent ${started.name} was stopped before it finished.\nIt left no closing message.`, ) }) @@ -3258,7 +3258,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.\nIt left no closing message.`, + `Background subagent ${started.name} was stopped before it finished.\nIt left no closing message.`, ) }) @@ -3295,7 +3295,7 @@ describe('continuable settlement delivery', () => { expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.\nIts closing message:\nthe answer`, + `Background subagent ${started.name} was stopped before it finished.\nIts closing message:\nthe answer`, ) }) @@ -3317,7 +3317,7 @@ describe('continuable settlement delivery', () => { await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, + `Background subagent ${started.name} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`, ) await expect(started.result).resolves.toMatchObject({ stopReason: 'completed' }) await expect(started.dispose()).rejects.toThrow('scope unwind failed') @@ -3393,7 +3393,7 @@ describe('continuable settlement delivery', () => { // message when that parent is disposed next is pinned by the test below. expect(settlementNotices(parent)).toHaveLength(1) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.\nIt left no closing message.`, + `Background subagent ${started.name} was stopped before it finished.\nIt left no closing message.`, ) expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index 9f15aaed61..654ce3d54b 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -27,8 +27,9 @@ async function setup(start: (request: SubagentStartRequest) => Promise ctx.subagents.startActivation({ + start: (delivery: 'parent' | 'caller' = 'caller', name?: string) => ctx.subagents.startActivation({ provider: 'external', label: 'External work', signal: controller.signal, delivery, + ...name === undefined ? {} : { name }, parent, prompt: [{ type: 'text', text: 'Work independently' }], }), records: () => ctx.sessionProjections.snapshot(parent.session, ['subagentCatalog']).values.subagentCatalog, @@ -389,3 +390,42 @@ it.each(['completed', 'aborted'] as const)('reports external %s without offering expect(JSON.stringify(fixture.parent.inbox.nextTurn)).toContain(stopReason === 'completed' ? 'cannot receive follow-up messages' : 'was stopped before it finished') expect(JSON.stringify(fixture.parent.inbox.nextTurn)).not.toContain('send it more') }) + +it('records a parent-delivered external run under its requested name', async () => { + const backend = execution() + const fixture = await setup(async () => backend.run) + const activation = await fixture.start('parent', 'auditor') + expect(activation.name).toBe('auditor') + expect(fixture.records()).toMatchObject([{ id: activation.childId, mode: 'external', name: 'auditor' }]) + backend.result.resolve(completed) + backend.cleanup.resolve(undefined) + await activation.dispose() +}) + +it('resolves a named external run but not a path below it', async () => { + const backend = execution() + const fixture = await setup(async () => backend.run) + const activation = await fixture.start('parent', 'auditor') + const signal = new AbortController().signal + expect(await fixture.ctx.subagents.resolveName(fixture.parent, 'auditor', signal)).toBe(activation.childId) + // The external run owns no local Session, so a further segment cannot name a descendant. + await expect(fixture.ctx.subagents.resolveName(fixture.parent, 'auditor/deeper', signal)) + .rejects.toMatchObject({ code: 'AGENT_NAME_NOT_FOUND' }) + backend.result.resolve(completed) + backend.cleanup.resolve(undefined) + await activation.dispose() +}) + +it('rejects a name for a caller-delivered external run that never enters the catalog', async () => { + const start = vi.fn<(request: SubagentStartRequest) => Promise>() + const fixture = await setup(start) + await expect(fixture.start('caller', 'auditor')).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + expect(start).not.toHaveBeenCalled() + const backend = execution() + start.mockResolvedValue(backend.run) + const unnamed = await fixture.start('caller') + expect(unnamed.name).toBe(unnamed.childId) + backend.result.resolve(completed) + backend.cleanup.resolve(undefined) + await unnamed.dispose() +}) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 7fd31fe301..be656f9f80 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -135,8 +135,8 @@ describe('direct-child catalog reader', () => { const children = await listChildren(ctx, parent.id) expect(children.map(({ createdAt: _createdAt, ...child }) => child)).toEqual([ - { id: callerOwned.childId, label: 'spawn', mode: 'continuable' }, - { id: continuableId, label: 'continuable child', mode: 'continuable' }, + { id: callerOwned.childId, label: 'spawn', mode: 'continuable', name: 'agent-1' }, + { id: continuableId, label: 'continuable child', mode: 'continuable', name: 'agent-2' }, ]) expect(children.every(child => Number.isFinite(child.createdAt))).toBe(true) expect(listSessions).not.toHaveBeenCalled() diff --git a/packages/subagent/subagent/tests/manager-internals.ts b/packages/subagent/subagent/tests/manager-internals.ts index 5b2e65ca56..3cb20995eb 100644 --- a/packages/subagent/subagent/tests/manager-internals.ts +++ b/packages/subagent/subagent/tests/manager-internals.ts @@ -43,6 +43,7 @@ export function bindSubagentActivation( provider: 'fixture', root, ancestors, + path: undefined, releaseSlot() {}, handle: { agent: child, async dispose() {} }, outputStart: child.session.seq, diff --git a/packages/subagent/subagent/tests/sibling-messages.spec.ts b/packages/subagent/subagent/tests/sibling-messages.spec.ts index c82b6514ff..04a3a6b024 100644 --- a/packages/subagent/subagent/tests/sibling-messages.spec.ts +++ b/packages/subagent/subagent/tests/sibling-messages.spec.ts @@ -17,7 +17,7 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a import SubagentRuntime from '../src/index.ts' import { loadStoredSession } from './persistence-helpers.ts' import { TestSessionQuery } from './test-session-query.ts' -import { managerState, subagentManager } from './manager-internals.ts' +import { bindSubagentActivation, managerState, subagentManager } from './manager-internals.ts' import { requireLocalActivation } from '../src/activation.ts' /** A model reply, or a turn that stays open until its Agent is cancelled. */ @@ -92,6 +92,18 @@ async function start(ctx: Context, parent: Agent, marker: string, description = return started.childId } +/** Start one caller-delivery child under a requested or default name and return its receipt. */ +function startNamed(ctx: Context, parent: Agent, marker: string, name?: string, startSignal = signal) { + return ctx.subagents.startActivation({ + provider: 'spawn', + label: `task ${marker}`, + ...name === undefined ? {} : { name }, + prompt: [{ type: 'text', text: `TASK:${marker}` }], parent, + signal: startSignal, + delivery: 'caller', + }) +} + /** Wait until a child's first model request is open. */ async function running(ctx: Context, id: SessionId): Promise { await vi.waitFor(() => { expect(ctx.agents.get(id)?.status).toBe('running') }) @@ -116,7 +128,7 @@ function textOf(event: SessionEvent<'user/message'>): string { } describe('subagent sibling messages', () => { - it('keeps duplicate descriptions as display text and addresses siblings by Session id', async () => { + it('keeps duplicate descriptions as display text and names siblings uniquely', async () => { const { ctx, parent } = await setup({ a: ['hang'], b: [textResponse('done'), textResponse('resumed')] }) const a = await start(ctx, parent, 'a') const sender = await running(ctx, a) @@ -124,15 +136,15 @@ describe('subagent sibling messages', () => { await settled(ctx, b) expect(a).not.toBe(b) expect(await listChildren(ctx, parent.id)).toEqual([ - expect.objectContaining({ id: a, mode: 'continuable', label: 'shared responsibility' }), - expect.objectContaining({ id: b, mode: 'continuable', label: 'shared responsibility' }), + expect.objectContaining({ id: a, mode: 'continuable', label: 'shared responsibility', name: 'agent-1' }), + expect.objectContaining({ id: b, mode: 'continuable', label: 'shared responsibility', name: 'agent-2' }), ]) expect(parent.session.snapshotEvents().filter(event => event.type === 'subagent/catalog').map(event => event.data.version)) - .toEqual([0, 0]) + .toEqual([3, 3]) await ctx.subagents.sendMessage(sender, b, [{ type: 'text', text: 'hello' }], { signal }) const delivered = (await storedUserMessages(ctx, b)).find(event => event.data.source.kind === 'agent-message')! - expect(textOf(delivered)).toBe(`Agent ${a} sent a message: hello`) + expect(textOf(delivered)).toBe('Agent agent-1 sent a message: hello') expect(delivered.data.source).toEqual({ kind: 'agent-message', form: 'relay', senderSessionId: a }) ctx.subagents.interrupt(a, { kind: 'ancestor', agent: parent }) await settled(ctx, a) @@ -198,9 +210,9 @@ describe('tree-wide messages', () => { await ctx.subagents.sendMessage(parent, leafId, [{ type: 'text', text: 'grandchild' }], { signal }) await ctx.subagents.sendMessage(leaf, parent.id, [{ type: 'text', text: 'grandparent' }], { signal }) expect(received).toEqual([ - { target: leafId, sender: a, text: `Agent ${a} sent a message: cousin` }, - { target: leafId, sender: parent.id, text: `Agent ${parent.id} sent a message: grandchild` }, - { target: parent.id, sender: leafId, text: `Agent ${leafId} sent a message: grandparent` }, + { target: leafId, sender: a, text: 'Agent agent-1 sent a message: cousin' }, + { target: leafId, sender: parent.id, text: 'Agent lead sent a message: grandchild' }, + { target: parent.id, sender: leafId, text: 'Agent agent-2/agent-1 sent a message: grandparent' }, ]) expect(() => { ctx.subagents.interrupt(leafId, { kind: 'ancestor', agent: sender }) }) .toThrow(expect.objectContaining({ code: 'UNAUTHORIZED' })) @@ -226,7 +238,7 @@ describe('tree-wide messages', () => { const messages = await storedUserMessages(ctx, leafId) const delivered = messages.find(event => event.data.source.kind === 'agent-message')! - expect(textOf(delivered)).toBe(`Agent ${a} sent a message: cold cousin`) + expect(textOf(delivered)).toBe('Agent agent-2 sent a message: cold cousin') expect(delivered.data.source).toEqual({ kind: 'agent-message', form: 'relay', senderSessionId: a }) await settled(ctx, branchId) expect(resumed).toEqual([{ id: leafId, parent: branchId }]) @@ -355,7 +367,7 @@ describe('tree-wide messages', () => { expect(ctx.agents.get(branchId)).toBe(branch) release.resolve(undefined) const messages = await storedUserMessages(ctx, leafId) - expect(messages.some(message => textOf(message) === `Agent ${parent.id} sent a message: after close`)).toBe(true) + expect(messages.some(message => textOf(message) === 'Agent lead sent a message: after close')).toBe(true) await settled(ctx, branchId) } finally { release.resolve(undefined) @@ -364,3 +376,96 @@ describe('tree-wide messages', () => { } }) }) + +describe('agent names', () => { + it('records requested and default names and rejects duplicates and invalid names before startup', async () => { + const { ctx, parent, adapter } = await setup({ a: ['hang'], b: ['hang'] }) + const reviewer = await startNamed(ctx, parent, 'a', 'reviewer') + const unnamed = await startNamed(ctx, parent, 'b') + expect([reviewer.name, unnamed.name]).toEqual(['reviewer', 'agent-1']) + for (const name of ['reviewer', 'agent-1']) { + await expect(startNamed(ctx, parent, 'c', name)).rejects.toMatchObject({ code: 'DUPLICATE_AGENT_NAME' }) + } + for (const name of ['lead', 'Reviewer', '1st', 'a'.repeat(33), 'review/er', '']) { + await expect(startNamed(ctx, parent, 'c', name)).rejects.toMatchObject({ code: 'INVALID_AGENT_NAME' }) + } + expect(adapter.routed.some(request => JSON.stringify(request.messages).includes('TASK:c'))).toBe(false) + expect((await listChildren(ctx, parent.id)).map(entry => 'name' in entry ? entry.name : undefined)) + .toEqual(['reviewer', 'agent-1']) + for (const started of [reviewer, unnamed]) { + ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent }) + await settled(ctx, started.childId) + } + }) + + it('holds a requested name across concurrent startup and releases it when startup fails', async () => { + const { ctx, parent } = await setup({ a: ['hang'], b: ['hang'], c: ['hang'] }) + const first = startNamed(ctx, parent, 'a', 'worker') + const concurrent = startNamed(ctx, parent, 'c') + await expect(startNamed(ctx, parent, 'b', 'worker')).rejects.toMatchObject({ code: 'DUPLICATE_AGENT_NAME' }) + const started = await first + const aborted = new AbortController() + aborted.abort(new Error('cancelled')) + await expect(startNamed(ctx, parent, 'b', 'helper', aborted.signal)).rejects.toThrow('cancelled') + const retried = await startNamed(ctx, parent, 'b', 'helper') + const sibling = await concurrent + expect([started.name, sibling.name, retried.name]).toEqual(['worker', 'agent-1', 'helper']) + for (const child of [started, retried, sibling]) { + ctx.subagents.interrupt(child.childId, { kind: 'ancestor', agent: parent }) + await settled(ctx, child.childId) + } + }) + + it('names nested members by path and resolves names without granting authority', async () => { + const { ctx, parent } = await setup({ branch: ['hang'], leaf: ['hang'] }) + const branch = await startNamed(ctx, parent, 'branch', 'reviewer') + const branchAgent = await running(ctx, branch.childId) + const leaf = await startNamed(ctx, branchAgent, 'leaf', 'checker') + const leafAgent = await running(ctx, leaf.childId) + expect(leaf.name).toBe('reviewer/checker') + expect(ctx.subagents.namesOf(parent)).toEqual({ self: 'lead' }) + expect(ctx.subagents.namesOf(branchAgent)).toEqual({ self: 'reviewer', parent: 'lead' }) + expect(ctx.subagents.namesOf(leafAgent)).toEqual({ self: 'reviewer/checker', parent: 'reviewer' }) + const resolve = (name: string) => ctx.subagents.resolveName(leafAgent, name, signal) + expect(await Promise.all(['lead', 'reviewer', 'reviewer/checker', 'missing', 'Not a name', leaf.childId].map(resolve))) + .toEqual([parent.id, branch.childId, leaf.childId, 'missing', 'Not a name', leaf.childId]) + // A slash-separated address is never a Session id, so an unmatched path fails instead of reaching member lookup. + await expect(resolve('reviewer/missing')).rejects.toMatchObject({ code: 'AGENT_NAME_NOT_FOUND' }) + await expect(resolve('reviewer/checker/deeper')).rejects.toMatchObject({ code: 'AGENT_NAME_NOT_FOUND' }) + expect((await ctx.subagents.listDescendants(parent.id, signal)).map(entry => entry.path)) + .toEqual(['reviewer', 'reviewer/checker']) + ctx.subagents.interrupt(branch.childId, { kind: 'ancestor', agent: parent }) + ctx.subagents.interrupt(leaf.childId, { kind: 'ancestor', agent: parent }) + await settled(ctx, branch.childId) + await settled(ctx, leaf.childId) + }) + + it('restores a cold member\'s path from the catalogs along its creation chain', async () => { + const { ctx, parent } = await setup({ branch: ['hang'], leaf: [textResponse('first'), 'hang'] }) + const branch = await startNamed(ctx, parent, 'branch', 'reviewer') + const branchAgent = await running(ctx, branch.childId) + const leaf = await startNamed(ctx, branchAgent, 'leaf', 'checker') + await settled(ctx, leaf.childId) + ctx.subagents.interrupt(branch.childId, { kind: 'ancestor', agent: parent }) + await settled(ctx, branch.childId) + + await ctx.subagents.sendMessage(parent, leaf.childId, [{ type: 'text', text: 'resume' }], { signal }) + const resumed = await running(ctx, leaf.childId) + expect(ctx.subagents.namesOf(resumed)).toEqual({ self: 'reviewer/checker', parent: 'reviewer' }) + ctx.subagents.interrupt(leaf.childId, { kind: 'ancestor', agent: parent }) + await settled(ctx, leaf.childId) + }) + + it('addresses members below an unnamed historical creator by Session id', async () => { + const { ctx, parent } = await setup({ legacy: ['hang'], leaf: ['hang'] }) + const legacy = await ctx.agentLoop.create(SessionId('legacy-child'), { provider: 'mock', model: 'mock' }) + bindSubagentActivation(ctx, legacy, parent) + expect(ctx.subagents.namesOf(legacy)).toEqual({ self: 'legacy-child', parent: 'lead' }) + const leaf = await startNamed(ctx, legacy, 'leaf', 'checker') + const leafAgent = await running(ctx, leaf.childId) + expect(leaf.name).toBe(leaf.childId) + expect(ctx.subagents.namesOf(leafAgent)).toEqual({ self: leaf.childId, parent: 'legacy-child' }) + ctx.subagents.interrupt(leaf.childId, { kind: 'ancestor', agent: parent }) + await settled(ctx, leaf.childId) + }) +}) diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index a9ed6c3b89..36dc72bfa8 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -9,8 +9,8 @@ en: 465d36925c792167 zh: 269cac314f638827 /deepseek-ai-dsh-tool-subagent-control/summary: - en: 7e68f9a2d58e1ab6 - zh: 24edd923e898cc3b + en: cba97055d8a7fec5 + zh: 11f22dcf2670b878 /deepseek-ai-dsh-tool-subagent-control/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -21,29 +21,29 @@ en: d224de77b41ed4e4 zh: f1e94f9cc9e04384 /deepseek-ai-dsh-tool-subagent-control/use-this-package/send-message: - en: 849b9b3e576aed01 - zh: d52239ca40d3a168 + en: fa4b7d45c45e172f + zh: d74e402d32b48b1c /deepseek-ai-dsh-tool-subagent-control/use-this-package/interrupt-agent: - en: de45d5a4b98d3c91 - zh: 666aa187170b9c0d + en: ac4cfa54d61e96a7 + zh: 367e4109549eac31 /deepseek-ai-dsh-tool-subagent-control/use-this-package/wait-agent: en: a7d5ecc38bd7a94b zh: 732c5e0ef5abc2a0 /deepseek-ai-dsh-tool-subagent-control/use-this-package/list-agents: - en: 912206fb3da120b5 - zh: 3b247046a52d5f2a + en: ed19a95c940c048b + zh: a734ef9e4892b971 /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation: en: c5cdf01a78c990f2 zh: 53c180d726ce4502 /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/design-concept: - en: be9274f5d07f0403 - zh: 6f05c769249e0a30 + en: 171cc10f5b343249 + zh: ab7fa6bf4394b61c /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/delivery-and-signal-ownership: - en: 65137f5fe14ebb7f - zh: ce00e4f32709246a + en: c4dffa4789053044 + zh: 5c890ec2393f3261 /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/listing-projection: - en: ee3136b09f0c9ef2 - zh: 5cbe189bb857fdc7 + en: 34b0b770c7ae6726 + zh: d8c589b6bd62be0b /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/source-map: en: 988d8adc49bfc0c2 zh: b3091ceb8d6dcad3 @@ -57,8 +57,8 @@ en: e4c7376d4f29dbd5 zh: 6b7a9eaf69c9ea53 /deepseek-ai-dsh-tool-subagent-control/model-experience/tool-schema/what-the-model-sees: - en: db2e45684a4d0117 - zh: 5cd3b646c010b530 + en: 102ef57f634f4d01 + zh: 571304c1e6e5dd1a /deepseek-ai-dsh-tool-subagent-control/model-experience/tool-schema/token-effect: en: 5eb1d46a94b7ec1b zh: 5670760ca695d4ad @@ -69,20 +69,23 @@ en: fc88c8ffb8ebc083 zh: 0baf2e5df37d813e /deepseek-ai-dsh-tool-subagent-control/model-experience/identity-and-communication-context/what-the-model-sees: - en: f15da8ae032d1133 - zh: d778adb601756c27 + en: 3f59216388405b66 + zh: 70672463706bc5c5 +/deepseek-ai-dsh-tool-subagent-control/model-experience/identity-and-communication-context/what-the-model-sees/subagent-identity: + en: fc2b3383d52ded16 + zh: d5074e59b79c7afd /deepseek-ai-dsh-tool-subagent-control/model-experience/identity-and-communication-context/token-effect: - en: e2c1a9e7f3a0bbea - zh: d13f6963c8b2a8c5 + en: 37a7eed766856ea0 + zh: 4a564f74e08a3252 /deepseek-ai-dsh-tool-subagent-control/model-experience/identity-and-communication-context/kv-cache-effect: - en: feed847f7a028d67 - zh: 623a3438346d2bf8 + en: 49dc6a07ae9640e3 + zh: 465fcd7cfe452ad2 /deepseek-ai-dsh-tool-subagent-control/model-experience/interrupt-result: en: d64e936e83e89a2b zh: b9905c33af6cca45 /deepseek-ai-dsh-tool-subagent-control/model-experience/interrupt-result/what-the-model-sees: - en: 89305619897680ba - zh: e610a844fc09c53f + en: 917ab5f74a23687d + zh: 97dacd5811ef7d66 /deepseek-ai-dsh-tool-subagent-control/model-experience/interrupt-result/token-effect: en: 5d1bcef28b5f18da zh: 027d67d93d660e76 @@ -93,11 +96,11 @@ en: ab964e307bd37dca zh: 03f975252f838a42 /deepseek-ai-dsh-tool-subagent-control/model-experience/delivery-result/what-the-model-sees: - en: 8746cb9fa6557bfe - zh: 49828e113e901160 + en: 0b55f549e3a6fd6c + zh: 837e8b38ac42548a /deepseek-ai-dsh-tool-subagent-control/model-experience/delivery-result/token-effect: - en: f7e47f835a1e0628 - zh: d2818b98c773ad35 + en: 9454662d08749312 + zh: 7c3cf7788f461a1a /deepseek-ai-dsh-tool-subagent-control/model-experience/delivery-result/kv-cache-effect: en: f5d5f97a153f7c70 zh: 3d6bc05b9eadfbc2 @@ -105,11 +108,11 @@ en: 094e55d258f2ec2e zh: f364bc9046530bff /deepseek-ai-dsh-tool-subagent-control/model-experience/listing-result/what-the-model-sees: - en: 08c89e5dbbd691a8 - zh: 5d74e546d024735b + en: 3b88f602282087f5 + zh: d597e4681915d25a /deepseek-ai-dsh-tool-subagent-control/model-experience/listing-result/token-effect: - en: 027aaeb808238388 - zh: 5b974abc429d9e23 + en: 8b5491a6a5459bb3 + zh: 87338c341aa9aa56 /deepseek-ai-dsh-tool-subagent-control/model-experience/listing-result/kv-cache-effect: en: 4a629edb36a17e32 zh: 5a7539cfbf404c54 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index ada5575c16..a66d8ce145 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-subagent-control` lets agents discover and message their subagent tree by Session id, interrupt descendants, and wait for their own next-step inbox. By default, one plugin instance registers all four tools; members inherit the same definitions and ordering. +`dsh-tool-subagent-control` lets agents discover and message their subagent tree by name, interrupt descendants, and wait for their own next-step inbox. By default, one plugin instance registers all four tools; members inherit the same definitions and ordering. ## Table of Contents @@ -44,11 +44,11 @@ Set `config.interrupt: false` to omit `interrupt_agent`. The default is `true`. ### send_message -Sends a message to another agent in the same subagent tree by `agent_id`. The caller must be the live root or a resident continuable subagent. A working target receives Steer input at its next step; an inactive target starts or resumes a turn. Only the target resumes; its creation ancestors stay offline. Success returns inbox acceptance (`messageId`), never a reply. Self, unrelated trees, unsupported targets, unknown ids, unavailable history, and rejected admission return errors. +Sends a message to another agent in the same subagent tree. `agent` takes a name from `list_agents`: `lead` for the root or a full name such as `reviewer/checker`. Text that is not a name in the tree is treated as a Session id, so Session ids also work, including for members listed by Session id. The caller must be the live root or a resident continuable subagent. A working target receives Steer input at its next step; an inactive target starts or resumes a turn. Only the target resumes; its creation ancestors stay offline. Success returns inbox acceptance (`messageId`), never a reply. Self, unrelated trees, unsupported targets, unknown names or ids, unavailable history, and rejected admission return errors. ### interrupt_agent -`agent_id` identifies a descendant by Session id. External executions stop permanently and cannot receive follow-ups. Local targets stop only their current turn: queued messages stay parked until a later `send_message`, descendants keep running, and the child stays available for follow-ups. The call returns when the stop request is accepted, not when the target is quiet; interrupting an already-finished agent is an accepted no-op, and self, sibling, stale, and non-ancestor callers get errored results. +`agent` names a descendant the same way `send_message` names its target. External executions stop permanently and cannot receive follow-ups. Local targets stop only their current turn: queued messages stay parked until a later `send_message`, descendants keep running, and the child stays available for follow-ups. The call returns when the stop request is accepted, not when the target is quiet; interrupting an already-finished agent is an accepted no-op, and self, sibling, stale, and non-ancestor callers get errored results. ### wait_agent @@ -57,7 +57,7 @@ Waits for the exact caller’s next-step inbox. Pending input returns immediatel ### list_agents -Lists the root and all reachable continuable descendants, including the caller, in stable pre-order. Descendant rows carry their direct parent id and depth from the root. Live status is `running` or `inactive`. External entries are omitted without reading child Sessions; readable one-shot children remain traversal nodes. Unknown modes and unreadable catalogs produce diagnostics. Ordinary Session forks lead separate subagent trees. +Lists the root and all reachable continuable descendants, including the caller, in stable pre-order. Each row carries a `name`, a `status`, and, for the root and continuable descendants, a `description`. The root is `lead` with the description `root agent`; a descendant's name is its full path from the root and its description is its creation label. A child whose catalog entry has no name, and every descendant below it, is listed by Session id. Live status is `running` or `inactive`. External entries are omitted without reading child Sessions; readable one-shot children remain traversal nodes. Unknown modes and unreadable catalogs produce rows whose status is `unsupported`, `corrupt`, or `unavailable`; the status reports that the row's own descendants could not be read, while the row itself stays an addressable member. Ordinary Session forks lead separate subagent trees. ----- @@ -71,15 +71,15 @@ This section explains what the tools delegate to the subagent service; the obser ### Design concept -Messaging, interruption, and discovery delegate to `ctx.subagents`; residency, cold resume, and authorization belong to that service. The wait tool subscribes to the exact caller’s inbox events before starting the timer and checking pending next-step input, and removes its listeners and timer on every settlement. Each successful wait records, per Agent, the pending ids it reported and its root tool call; a later wait from the same root call ignores those ids. +Messaging, interruption, and discovery delegate to `ctx.subagents`; residency, cold resume, and authorization belong to that service. Messaging and interruption first map `agent` to a Session id through `ctx.subagents.resolveName()`, which grants no authority; the service then authorizes that Session id. The wait tool subscribes to the exact caller’s inbox events before starting the timer and checking pending next-step input, and removes its listeners and timer on every settlement. Each successful wait records, per Agent, the pending ids it reported and its root tool call; a later wait from the same root call ignores those ids. ### Delivery and signal ownership -The execution signal owns work only until inbox acceptance. Accepted messages cannot be cancelled through this tool. Every message is framed as `Agent sent a message:` and recorded with `{ kind: 'agent-message', form: 'relay', senderSessionId: sender.id }`. Tree membership authorizes communication; the subagent tree root owns runtime instances and capacity. +The execution signal owns work only until inbox acceptance. Accepted messages cannot be cancelled through this tool. Every message is framed as `Agent sent a message:` and recorded with `{ kind: 'agent-message', form: 'relay', senderSessionId: sender.id }`. Tree membership authorizes communication; the subagent tree root owns runtime instances and capacity. ### Listing projection -`list_agents` resolves the caller’s live root and traverses its catalogs without a cursor, refining activity through the live Agent registry. Unknown modes remain traversable with diagnostics; unreadable catalogs stop that branch. Sessions absent from reachable catalogs cannot be discovered. +`list_agents` resolves the caller’s live root and traverses its catalogs without a cursor, refining activity through the live Agent registry. Names come from the `path` of each `listDescendants()` row. Unknown modes remain traversable with diagnostics; unreadable catalogs stop that branch. Sessions absent from reachable catalogs cannot be discovered. ### Source map @@ -111,7 +111,7 @@ Read these pages when the package-level contract is not enough; they move from t #### What the model sees -The generated [schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control) define id-addressed messaging and interruption, whole-tree discovery, and inbox waiting with `timeout_ms`. `wait_agent` returns `{ "timedOut": false }` for available input, and `{ "timedOut": true }` on timeout. It returns no message body or peer-activity diagnosis. +The generated [schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control) define name-addressed messaging and interruption, whole-tree discovery, and inbox waiting with `timeout_ms`. `wait_agent` returns `{ "timedOut": false }` for available input, and `{ "timedOut": true }` on timeout. It returns no message body or peer-activity diagnosis. #### Token effect @@ -125,21 +125,32 @@ Prefix-stable; the schemas do not change at runtime, and a parent and its contin #### What the model sees -Required `subagent:identity` runtime context identifies each agent by its Session id. Delegated children are identified as subagents and receive their direct parent and root ids. It is independent of Team mode and tool filtering; a missing live root is reported without preventing independent work. When the live root is available, identity context includes messaging and discovery instructions independently for each admitted standard tool. Automatic result delivery remains owned by the subagent service. +Required `subagent:identity` runtime context identifies each agent by name. It is independent of Team mode and tool filtering. When the live root is available, identity context includes messaging and discovery instructions independently for each admitted standard tool. The root sees `You are the root agent. Your name is "lead".` followed by the same guidance. A missing live root is reported with the agent's Session id, without preventing independent work. Automatic result delivery remains owned by the subagent service. A child of the root named `reviewer`, with both tools admitted, sees: + +##### Subagent identity + +```markdown +You are a subagent delegated by agent "lead". +Your name is "reviewer". +The root agent of your collaboration tree is "lead". + +To message other agents, use send_message({ agent: "", message: "..." }). +Use list_agents({}) to find your collaborator agents and their names. +``` #### Token effect -One short context entry per agent, independent of tree size. +One short context entry per agent, independent of tree size; full names lengthen with nesting depth. #### KV Cache effect -Identity and communication guidance follow inherited history as logged runtime context, leaving the shared system prompt and tool definitions unchanged. This required context remains available with `includeRuntimeContext: false` and is restored after compaction. Team mode does not change it. +Identity and communication guidance follow inherited history as logged runtime context, leaving the shared system prompt and tool definitions unchanged. When a retained snapshot's identity text differs from the current text, one current runtime-context snapshot is appended. This required context remains available with `includeRuntimeContext: false` and is restored after compaction. Team mode does not change it. ### Interrupt result #### What the model sees -`interrupt requested for agent ` on acceptance. An unauthorized caller — self, sibling, stale, or non-ancestor — is an errored result naming the rejection; an absent or settled target still renders the acceptance line. +`interrupt requested for agent ` on acceptance, echoing the `agent` argument. An unauthorized caller — self, sibling, stale, or non-ancestor — is an errored result naming the rejection; an absent or settled target still renders the acceptance line. #### Token effect @@ -153,11 +164,11 @@ Append-only; each result follows the reusable request prefix. #### What the model sees -`message delivered to agent ` on acceptance; canonical output carries the accepted `messageId`. A target outside the caller’s subagent tree or rejected admission produces an errored result. +`message delivered to agent ` on acceptance, echoing the `agent` argument; canonical output carries the accepted `messageId`. A target outside the caller’s subagent tree or rejected admission produces an errored result. #### Token effect -One short acknowledgement per call; replies arrive as separately addressed messages. Use ids from `list_agents` for follow-ups across the subagent tree. +One short acknowledgement per call; replies arrive as separately addressed messages. Use names from `list_agents` for follow-ups across the subagent tree. #### KV Cache effect @@ -167,11 +178,11 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -The first row is ` [] — root agent`. Each continuable descendant follows as ` [] parent= depth= —