diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index aad3aa26f0..10a29ee499 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md -2026-07-07-tool-call-timeout-policy.md: ce414e541f8e374dd48e46d68cb00121e0004247 -2026-07-07-tool-call-timeout-policy.zh.md: 6fe3c979a3c4e7b7a6ed803a47af45ad32d52cce +2026-07-07-tool-call-timeout-policy.md: 92618cc8c761b38d7e516c9d00eb3de1c37831a8 +2026-07-07-tool-call-timeout-policy.zh.md: cc633ceaa3840331826f6475e603e78a98f0afd2 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index ce414e541f..92618cc8c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -77,7 +77,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin ### Existing tool adaptation -`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`. +`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` has no `timeout_ms` parameter, while `web_search` accepts a required `queries` array without a timeout argument. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`. `dsh-web-fetch-http` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls. diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index 6fe3c979a3..cc633ceaa3 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -77,7 +77,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { ### 现有工具适配 -`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent(智能体)的形状,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。 +`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 没有 `timeout_ms` 参数,`web_search` 接受必填的 `queries` 数组,但不接受超时参数。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。 `dsh-web-fetch-http` 保留一个在提供方层面配置的 `timeoutMs`,作为较大的资源兜底值,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误的部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,而外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜出。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index ecca8ab2b0..007b9c0d73 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c6c5a8f2faffd3e03462eaad159ae94c53c735ce -2026-07-30-client-locale-full-rollout.zh.md: 8d6220784104944f5d07b533e4f107ceffdfcfea +2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab +2026-07-30-client-locale-full-rollout.zh.md: 0c05ec9699700d88d786bc661f7013f2ed09ebb2 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c6c5a8f2fa..6701aefa45 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -42,4 +42,4 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. - ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. -- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. +- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 8d62207841..0c05ec9699 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -42,4 +42,4 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。 - ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。 -- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。 +- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index 4012912001..5e158d2c8b 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md -2026-07-30-settings-write-path-integrity.md: c01f04a9b88417115505a8fc9fd3641055e95472 -2026-07-30-settings-write-path-integrity.zh.md: 967acf3266451e5a3974b37bc5703e5d45592007 +2026-07-30-settings-write-path-integrity.md: 7a2d377586ff2bfa7caeb9d4196ee99f70d3e63f +2026-07-30-settings-write-path-integrity.zh.md: fa68bfba04382d6cafd03bf174ebadcd6bafd519 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md index c01f04a9b8..7a2d377586 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -14,7 +14,7 @@ The provider's write path could destroy state it never observed, and the Service **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. -**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config. +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. `EEXIST` identifies contention directly; `EPERM` identifies it only when `lstat` confirms that the lock path exists, because Windows may report permission denial for an exclusive create against that existing path. An unrelated permission failure remains loud. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config. **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index 967acf3266..fa68bfba04 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 **单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其「告警并保留最后可用值」策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 -**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。 +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。`EEXIST` 直接表示竞争;只有 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,因为 Windows 可能把针对该现有路径的独占创建报告为权限拒绝。无关的权限故障仍会响亮失败。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。 **观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件约定现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index b9b20c4ad9..c8e0a64d5d 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 94e3ddad0cfea0a1aaf2efe8d15f26f471bb2bd3 -2026-08-05-profile-plugin-bundles.zh.md: 96d22c57f68ecc72e1afd34fbbd3cfbe3ac24c61 +2026-08-05-profile-plugin-bundles.md: c9f685eecddcd4ea8d8580becef3c32a9693a329 +2026-08-05-profile-plugin-bundles.zh.md: 43680862a13171071e236d56c336b790bbe50432 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 94e3ddad0c..c9f685eecd 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. +The default Profile templates use `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 96d22c57f6..43680862a1 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag ,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 +默认 Profile 模板使用的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 205757eacc..2b083305ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: a747d641ae112d114912958c289fe00b592e6ea5 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: fef69e8a2d18135cbc9d5f0d80134fa5701bbbd0 +2026-08-10-product-subagent-providers-in-shared-host.md: 196e28c1263c4b6d71eaeb59b9ba8457b36f3ff4 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: b36398be50065dd1520fb97ca15416467c580bf2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index a747d641ae..196e28c126 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -6,36 +6,34 @@ English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md) ## Problem -The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique within the Host, and host consumers resolve the same registry across sessions. Repeated preset composition would therefore contend for the same configured names. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. -The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. +The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while granting a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. ## Decision -Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts it once on the host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry. +Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider Bundle; its patch mounts the default instance, and the Profile may mount additional named instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. -This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. +Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. -The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, test authentication, or add product-specific settings. Missing commands and product failures remain local to the attempted delegation. - -Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable. +Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation. ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove the Codex-only and dual-provider opt-in paths register the selected providers without starting a product process. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and process-tree quiescence. ## Alternatives considered -**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure but requires the user to edit both a Profile and a Preset. The production-install exclusion decision accepts that installation trade-off; this note retains the requirement that any selected provider is mounted once on the host plane rather than inside the preset. +**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure but requires the user to edit both a Profile and a Preset. The production-install exclusion decision accepts that installation trade-off; this note retains the requirement that selected provider instances are mounted on the host plane rather than inside the preset. **Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state. -**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime. +**Mount providers inside every Agent Preset.** Provider names belong to a process registry, so repeated session composition would collide on the same configured names. Host consumers also need the registry independently of any one agent's lifetime. **Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state. ## Consequences -A user installs each selected product provider in a Profile and exposes its tool through the same Agent Preset authoring path as other plugins. Each new session receives exactly the tools its chosen preset contributes. Profiles that do not select a product provider carry no corresponding package or module-loading footprint; loading a selected provider still starts no product process, login, model call, or product home. +A user installs each selected product provider in a Profile, mounts the required named instances, and exposes their tools through the same Agent Preset authoring path as other plugins. Each new session receives exactly the tools its chosen preset contributes. Profiles that do not select a product provider carry no corresponding package or module-loading footprint; loading selected instances still starts no product process, login, model call, or product home. -The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is a two-layer opt-in: the Profile owns installation and host-plane registration, while the Preset owns per-agent exposure. Selecting the Claude provider also accepts its current SDK optional-payload installation cost. +The Host registry remains the single provider authority, each Bundle remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index fef69e8a2d..b36398be50 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -6,36 +6,34 @@ Status: implemented ## 问题 -[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。 +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称在 Host 内唯一,而宿主消费方会跨会话解析同一个注册表。因此,重复组装 preset 会争用同一组已配置名称。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 配置项本身不完整。 -归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 +归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具授权仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 ## 决策 -产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。 +产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方 Bundle;其 patch 挂载默认实例,而 Profile 可以在 host plane 挂载更多命名实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 -本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 - -只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。 +每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。 ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置行。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明 Codex-only 与双提供方按需启用路径会注册选中的提供方,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 -**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但要求用户同时编辑 Profile 与 Preset。生产安装排除决策接受这项安装取舍;本说明保留的要求是,任何被选中的提供方都在 host plane 挂载一次,而不是放入 preset。 +**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但要求用户同时编辑 Profile 与 Preset。生产安装排除决策接受这项安装取舍;本说明保留的要求是,任何被选中的提供方实例都在 host plane 挂载,而不是放入 preset。 **存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。 -**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 +**在每个 Agent Preset 内挂载提供方。** 提供方名称属于进程级注册表,因此重复组装会话会在同一组已配置名称上发生冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 **交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。 ## 后果 -用户在 Profile 中安装每个被选中的产品提供方,再通过与其他插件相同的 Agent Preset 创作路径暴露它的工具。每个新会话只会获得其所选 preset 所贡献的工具。没有选择产品提供方的 Profile 不承担对应包或模块的加载开销;加载已选择的提供方仍不会启动产品进程、登录、调用模型或创建产品主目录。 +用户在 Profile 中安装每个被选中的产品提供方,挂载所需命名实例,再通过与其他插件相同的 Agent Preset 创作路径公开这些实例的工具。每个新会话只会获得其所选 preset 所贡献的工具。没有选择产品提供方的 Profile 不承担对应包或模块的加载开销;加载已选择的实例仍不会启动产品进程、登录、调用模型或创建产品主目录。 -宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是两层按需启用:Profile 负责安装与 host plane 注册,Preset 负责按 agent 暴露。选择 Claude 提供方还会接受当前 SDK 可选载荷的安装成本。 +Host 注册表仍是提供方的唯一权威,每个 Bundle 仍是部署可用性的权威,每个 Preset 仍是模型工具的权威。这个显式的双门生命周期避免全局启用开关,并让包移除与按会话创作保持独立。 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index d77b2c75c7..ce1925c821 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md -2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3 -2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7 +2026-08-12-plugin-owned-settings-surface.md: 722e6cfbe890418e8305f89790e76976027d7775 +2026-08-12-plugin-owned-settings-surface.zh.md: 93e5227d5f6a629fd32f5a2fe22e9882c7f7c5ac diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index 3137cfe81e..722e6cfbe8 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -22,7 +22,7 @@ Together the two meant a user-authored plugin was configurable only by hand-edit **`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list. -**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer. +**The tab drives dispatch from the served namespaces.** It derives the current served set from `ctx.settingsScope.describe()` and follows that shared settings mirror, while its own listener follows the card slot ledger. It dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the mirror answer. The later [settings describe mirror decision](2026-08-17-settings-describe-mirror.md) owns the browser-wide read and invalidation lifecycle. Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line. @@ -56,6 +56,6 @@ A plugin distributed outside this repository is configurable from the settings p Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path. -The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect. +The section and its cards add no `settings.describe` reads: both derive from the browser-wide mirror. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the mirror's current answer joins on the next commit or reconnect. Two frictions remain for an author outside this repository, both recorded in the section's README. The browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index 8dd5e5cceb..93e5227d5f 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -22,7 +22,7 @@ Status: implemented **`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。 -**标签页以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。 +**标签页以被服务的命名空间驱动派发。** 它从 `ctx.settingsScope.describe()` 派生当前被服务的集合并跟随该共享 settings 镜像,自身的监听器只跟随卡片 slot 账本;随后为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与镜像应答算出。后续的 [settings describe 镜像决策](2026-08-17-settings-describe-mirror.md)持有浏览器全局的读取与失效生命周期。 以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。 @@ -56,6 +56,6 @@ Status: implemented 以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。 -分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。 +分区与其中的卡片都不再新增 `settings.describe` 读取:两者都从浏览器全局的镜像派生。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在镜像当前应答之后才被注册的命名空间,要等下一次提交或重连才会加入。 对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml new file mode 100644 index 0000000000..35b8cb3aa3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md +2026-08-17-settings-describe-mirror.md: a3774699ff328a44aed192a16dea0fa19d03c83c +2026-08-17-settings-describe-mirror.zh.md: c57f5630b4bee0cd77a29a6f5458cb439c7f0585 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md new file mode 100644 index 0000000000..a3774699ff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md @@ -0,0 +1,34 @@ +# Agent Note: Settings describe mirror + +Status: implemented + +English | [中文](2026-08-17-settings-describe-mirror.zh.md) + +## Problem + +A cold web boot issued `settings.describe` fifteen times inside ~200ms, and the count grew by two with every client plugin that owned a preference. Two mechanisms stacked: `SettingsScopeBinder.bind()` started a full-document read per bound scope (six scopes in the product composition, plus the plugin-directory tab, the welcome gate, and the models onboarding join), and `onConnected` emits `connection/reset` on the FIRST connection too, so every one of those readers immediately re-read the answer it had fetched milliseconds earlier. Each reader also carried its own invalidation subscriptions and its own `refreshIfLoaded`-style guard, and fifteen independent reads could in principle land on fifteen different document revisions. + +## Decision + +**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun. The in-flight slot owns a run before its loading publication can synchronously reenter `load()`, then clears inside the run's own try/finally in the same synchronous segment that observes the rerun flag; a `.finally()` on the returned promise would run one microtask later and let a refresh landing in that gap mark a rerun nobody reads. + +`bind()` still returns the unchanged `SettingsScope` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; the fold invalidates any older in-flight answer, and a write before the first held document reruns that read instead of publishing a partial document. A failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace schema, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, the shared read/fold face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). + +This decision updates the browser read and invalidation mechanics recorded by [Host-backed Web preferences](../bug-fix/2026-08-06-host-backed-web-preferences.md) and [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), while preserving their preference-ownership and namespace-exposure decisions. It also replaces the direct settings-read description in [official DeepSeek first-run credential setup](../feature/2026-07-30-deepseek-onboarding-credential-setup.md); that join now derives its settings half from this mirror. + +The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budget.e2e.ts`: the mirror's eager bind-time read, plus the first-connection reset read, which is kept deliberately — it closes the window where a document commit lands between the eager HTTP read and the SSE subscription and its invalidation is lost. The plan's original target of one read is unreachable without either accepting that lost-invalidation window or delaying the first read until after the SSE stream opens. + +## Alternatives considered + +- **Single-flight sharing inside `bind()` only** — deduplicates the concurrent bursts but keeps N direct readers, N subscription sets, and the revision skew; readers outside the binder (welcome, models, tab, permission) gain nothing. Rejected as treating the symptom. +- **Boot-payload embedding** (host inlines the describe answer into the page boot) — saves the first read but adds a second acquisition path with its own staleness rules on top of the mirror it would still need. Deferred; it composes with the mirror if ever wanted. +- **Per-namespace `settings.describe(ns)`** — shrinks each answer but keeps one read per consumer, so the fan-out and the growth rate stay. Rejected. +- **One read (no first-reset re-read)** — reachable only by accepting the lost-invalidation window between the eager HTTP read and the SSE subscription, or by delaying the first read until the stream opens; both trade correctness or first-paint freshness for one loopback request. Rejected in favor of the pinned two. + +## Consequences + +- Startup `settings.describe` went 15 → 2, and a new preference-owning plugin adds zero reads. +- Every derived surface shows the same document revision at any moment; the per-reader guards (`refreshWelcomeIfLoaded`, `refreshPermissionIfLoaded`, `refreshDocumentIfLoaded`) and their subscriptions are gone. +- The mirror refreshes on every document commit regardless of namespace, so an external settings edit now costs one background read even while no settings surface is open — the price of surfaces that open already fresh. The per-namespace `ns !== spec.namespace` filters are gone with the per-scope subscriptions. +- `credentials.describe` (3 startup calls), `agentPreset.list` (2), and `llm.providers` are separate sources and stay direct; the same mirror pattern fits them if they ever need it. +- A new direct `settings.describe` caller in client code is a budget regression; the e2e's failure message says to grep for callers outside `ui-settings`. diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md new file mode 100644 index 0000000000..c57f5630b4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md @@ -0,0 +1,34 @@ +# Agent Note:Settings describe 镜像 + +Status: implemented + +[English](2026-08-17-settings-describe-mirror.md) | 中文 + +## 问题 + +一次冷启动的 web boot 在约 200ms 内发出十五次 `settings.describe`,且每新增一个持有偏好设置的客户端插件,该计数再加二。两个机制叠加:`SettingsScopeBinder.bind()` 为每个绑定的 scope 启动一次全量文档读取(产品组合中有六个 scope,外加插件目录 tab、welcome 门与 models onboarding join),而 `onConnected` 在**首次**连接时同样发出 `connection/reset`,于是上述每个读取方都立即重读了几毫秒前刚取到的应答。每个读取方还各自持有失效订阅与各自的 `refreshIfLoaded` 式防护,且十五次独立读取原则上可能落在十五个不同的文档 revision 上。 + +## 决定 + +**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读。在飞槽位会在 loading 发布同步重入 `load()` 之前先取得 run 的所有权,随后在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空;若把清理挂在返回 promise 的 `.finally()` 上,它要晚一个微任务执行,落入该间隙的刷新会标记一个无人读取的 rerun。 + +`bind()` 返回的 `SettingsScope` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;这次折叠会废弃更早发出的在飞应答,而首次完整文档尚未建立时到达的写入会让该读取重跑,不会把单个 namespace 发布成残缺文档。失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 schema 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 提供的共享读/折叠面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。 + +本决策更新了[通过 Host settings 持久化 Web 用户偏好](../bug-fix/2026-08-06-host-backed-web-preferences.md)和[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)所记录的浏览器读取与失效机制,同时保留其中关于偏好所有权与命名空间暴露的决策。它也取代了 [DeepSeek 官方首次使用凭据配置](../feature/2026-07-30-deepseek-onboarding-credential-setup.md)中的设置直读描述;该联接的 settings 部分现在从本镜像派生。 + +冷启动预算由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉在两次读取:镜像在绑定时的急切读取,加上首连 reset 触发的读取——后者是有意保留的:它关闭了「文档提交落在急切 HTTP 读取与 SSE 订阅之间、其失效通知丢失」的窗口。方案最初的一次读取目标,若不接受该失效丢失窗口、或不把首次读取推迟到 SSE 流建立之后,无法达成。 + +## 考虑过的备选 + +- **仅在 `bind()` 内做 single-flight 共享**——能去重并发风暴,但仍保留 N 个直连读取方、N 套订阅以及 revision 偏差;binder 之外的读取方(welcome、models、tab、permission)毫无受益。以治标为由否决。 +- **boot 载荷内嵌**(宿主把 describe 应答内联进页面 boot)——省下首次读取,却在镜像仍然需要的前提下增加第二条带自身陈旧规则的取数路径。推迟;若将来需要,它可与镜像叠加。 +- **按命名空间的 `settings.describe(ns)`**——缩小单次应答,但每个消费者仍各读一次,扇出与增长率原样保留。否决。 +- **一次读取(去掉首连 reset 重读)**——只有接受「急切 HTTP 读取与 SSE 订阅之间的失效丢失窗口」、或把首次读取推迟到流建立之后才可达成;两者都在用正确性或首屏新鲜度换一次环回请求。否决,保留钉住的两次。 + +## 后果 + +- 启动期 `settings.describe` 从 15 次降到 2 次,新增持有偏好设置的插件带来零次新增读取。 +- 任一时刻每个派生面看到的都是同一份文档 revision;各读取方的防护(`refreshWelcomeIfLoaded`、`refreshPermissionIfLoaded`、`refreshDocumentIfLoaded`)及其订阅随之消失。 +- 镜像对任何命名空间的文档提交都会刷新,因此在没有任何设置表面打开时,一次外部设置编辑现在也花费一次后台读取——这是「表面打开即新鲜」的代价。随着各 scope 订阅的删除,按命名空间的 `ns !== spec.namespace` 过滤一并消失。 +- `credentials.describe`(启动 3 次)、`agentPreset.list`(2 次)与 `llm.providers` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。 +- 客户端代码中新增直连 `settings.describe` 调用即是预算回归;e2e 的失败信息会提示在 `ui-settings` 之外 grep 调用方。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 87101e680a..10e09d4a49 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md -2026-08-06-host-backed-web-preferences.md: 5d90f2be7c8b4030e9bdc00eed2769491ec009e5 -2026-08-06-host-backed-web-preferences.zh.md: c861c45bff299e06841165a2b36d0781e8f54d99 +2026-08-06-host-backed-web-preferences.md: 2e33d05417bf6c347a57b5c0b6c7281ff1392b5b +2026-08-06-host-backed-web-preferences.zh.md: 1d3518bb33d334d89408916a5f5210a45b938a01 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md index 5d90f2be7c..2e33d05417 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -12,9 +12,9 @@ The first theme implementation moved only Appearance to Host settings but awaite ## Decision -The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. +The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace to a loopback client; field roles still redact secrets. -`dsh-client-ui-settings` provides `ctx.settingsScope.bind(spec)`, which owns one lifecycle per namespace as the browser mirror of the Host-side settings owner seam. It installs `settings/document-updated` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. +`dsh-client-ui-settings` owns one browser-wide settings describe mirror and provides `ctx.settingsScope.bind(spec)` as a per-namespace selector over it. The mirror installs `settings/document-updated` and `connection/reset` listeners before starting its background read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Each bound scope publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to, without adding a wire read or listener of its own. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. The shared read and invalidation lifecycle is specified by the later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md). User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md index c861c45bff..1d3518bb33 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -12,9 +12,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo ## 决策 -各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 +各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会向回环客户端服务每一个已注册的 namespace;字段角色仍会脱敏机密值。 -`dsh-client-ui-settings` 提供 `ctx.settingsScope.bind(spec)`,为每个 namespace 持有一份生命周期,作为 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。 +`dsh-client-ui-settings` 持有一个浏览器全局的 settings describe 镜像,并提供 `ctx.settingsScope.bind(spec)` 作为该镜像上的逐 namespace selector。镜像在开始后台读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。每个绑定的 scope 会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式),自身不再增加协议读取或监听器。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。共享读取与失效生命周期由后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)规定。 用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml new file mode 100644 index 0000000000..61134679f7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md +2026-08-06-plan-narrow-viewport-regression.md: 945d014e0c51cbaf4080e72f50ee60763d851698 +2026-08-06-plan-narrow-viewport-regression.zh.md: 37060129364c65b02cc1729331fc7334797863db diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md new file mode 100644 index 0000000000..945d014e0c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md @@ -0,0 +1,33 @@ +# Agent Note: narrow-viewport plan chip click-area regression test + +Status: implemented + +English | [中文](2026-08-06-plan-narrow-viewport-regression.zh.md) + +## Problem + +The external report dsh-external/issues#107 (clustered internally as deepseek-harness#1406) measured that at viewports between 760px and 850px the plan control and the model selector overlapped, with the model selector covering the plan control's click area so plan mode could not be left by mouse at 800×720. Its acceptance list asked for a browser regression test asserting that the plan center hit-tests to the plan button. + +The browser regression test reproduced the report on current master: at 800×720 the plan chip and the model trigger overlapped by 36.9px and the chip's center hit-tested to the trigger's label. The composer control row is `display: flex; justify-content: space-between` with `.trailing { flex: none }`: when the combined control width exceeds the card, the shrinking `.tools` group keeps its flow children inside its `min-width: 0` box, so the chip — the last flow child before the overflow — is painted over the trailing group. The plan-control form changed since the report (select → chip, `c20b988166`/`fe91919346`) and the row gained adaptive behavior (`c8c75ec891`, [web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)), but the row had no wrap, so the overlap survived both. + +## Decision + +The row wraps instead of shrinking its left group into the right group's area: `.row { flex-wrap: wrap }` plus `margin-left: auto` on `.trailing`, which re-anchors the trailing group (model + send) to the right edge of its wrapped line while `space-between` already pins it right on a single line. Wrapping is the acceptance's "wrap, fold, or re-arrange controls when space runs out" option, keeps every control at full width (no label folding that would hide the model name or the Plan wordmark), and holds at every viewport width by construction instead of at a calibrated container-query threshold. + +Add `apps/web/tests/plan-control-row.e2e.ts`: enter plan mode with the real `/plan` command (no argument — the command handler commits plan/mode active without a model round, the lifecycle-chrome precedent), so the test needs no model call in any mode and no API key in replay/refresh; a providers-only fixture mounts the model catalog without a script to consume. The file joins the host-plane e2e pairing like every sibling: excluded from the client graph in `apps/web/tsconfig.json` (it imports host-plane types) AND included in the host aggregate in `tsconfig.host.json`, so exactly one TypeScript program owns it — the pairing that also gives the lint type service its program. + +The geometry golden records stable facts — viewport membership on both axes and disjoint click areas — never absolute coordinates, whose pixel values depend on installed fonts and differ between macOS and Linux. The behavior assertions implement the acceptance directly: the click areas are disjoint, the click at the chip's center (Playwright's actionability check) leaves plan mode through the real command channel (`/plan off` via `commands.execute`), and the last `plan/mode` event in the session log flips inactive. + +## Alternatives considered + +**Seed a cold session (composer-tab-geometry pattern).** Rejected: the exit path executes `/plan off` through `commands.execute`, which needs the live agent a cold seeded session does not have; `connectFreshWorkspace` keeps one, matching the product's user path. + +**Pin absolute bounding boxes in the golden.** Rejected: chip and trigger widths depend on the installed fonts, so absolute coordinates would churn across platforms without a behavior change. + +**Reuse the plan-review fixture shape (exit_plan_mode review takeover).** Rejected: the takeover replaces the composer's control row, which is the surface under test. + +**Container-query label folding for the chip and/or the model trigger.** Rejected for the fix: two packages (ui-plan, ui-model) would need calibrated thresholds and the chip's own icon-only fold still leaves ~7px of overlap at the reported viewport unless the trigger folds too. Wrapping is one rule in one package and holds at every width. + +## Consequences + +Any future change to the control row layout — fonts, gaps, media or container queries — that re-introduces overlap or moves the chip out of the viewport on either axis fails this test. The test needs no API key in replay/refresh modes: plan mode toggles through the command handler without a model round, and a providers-only replay fixture (no recorded script, consumption check skipped) mounts the model directory so the trigger renders its real long label — the width that made the reported overlap measurable; the test asserts that label before measuring. The golden is compared in replay and record modes and rewritten in refresh mode. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md new file mode 100644 index 0000000000..3706012936 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 窄视口下 Plan chip 点击区域回归测试 + +Status: implemented + +[English](2026-08-06-plan-narrow-viewport-regression.md) | 中文 + +## 问题 + +外部报告 dsh-external/issues#107(内部聚类为 deepseek-harness#1406)测得视口宽度在 760px 到 850px 之间时 Plan 控件与模型选择器发生重叠,模型选择器覆盖 Plan 控件的点击区域,导致在 800×720 下无法用鼠标退出 Plan 模式。其验收清单要求增加浏览器回归测试,断言 Plan 中心命中 Plan 按钮。 + +浏览器回归测试在当前 master 上复现了报告:800×720 下 Plan chip 与模型 trigger 重叠 36.9px,chip 中心命中 trigger 的 label。composer 控制行是 `display: flex; justify-content: space-between` 且 `.trailing { flex: none }`:当控件总宽超过卡片时,可收缩的 `.tools` 组把流内子项留在 `min-width: 0` 的盒内,于是 chip——溢出前最后一个流内子项——被绘制到 trailing 组上方。报告以来 Plan 控件形态已变(select → chip,`c20b988166`/`fe91919346`),控制行也获得过自适应能力(`c8c75ec891`,[web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)),但该行没有换行,重叠在两次重构后依然存在。 + +## 决策 + +控制行换行而不是把左侧组收缩进右侧组的区域:`.row { flex-wrap: wrap }` 加上 `.trailing` 的 `margin-left: auto`——后者把 trailing 组(模型选择 + 发送)重新锚定到换行后的右缘,单行时 `space-between` 已把它钉在右侧。换行是验收中"空间不足时允许换行、折叠或重新排列控件"的选项,保持每个控件全宽(不做会隐藏模型名或 Plan 字样的 label 折叠),并且按构造在所有视口宽度下成立,而非依赖标定的容器查询阈值。 + +新增 `apps/web/tests/plan-control-row.e2e.ts`:通过真实 `/plan` 命令(无参数——命令 handler 不经模型回合即提交 plan/mode active,lifecycle-chrome 先例)进入 Plan 模式,因此测试在任何模式下都无需模型调用,仅在 replay/refresh 下无需 API key;providers-only fixture 挂载模型目录而无脚本可消费。该文件与所有同类 host 平面 e2e 一样采用成对登记:在 `apps/web/tsconfig.json` 的 exclude 列表(它导入 host 平面类型,client 图绝不编译它),同时在 `tsconfig.host.json` 的 host 聚合 include 中——恰好一个 TypeScript 程序拥有它,这也是 lint 类型服务获得程序的配对方式。 + +几何 golden 记录稳定事实——两个轴上的视口内位置与点击区域不相交——绝不记录绝对坐标,其像素值依赖安装字体且在 macOS 与 Linux 间不同。行为断言直接实现验收:点击区域不相交、点击 chip 中心(Playwright 的可操作性检查)经真实命令通道(`commands.execute` 执行 `/plan off`)退出 Plan 模式,且会话日志中最后一条 `plan/mode` 事件翻转为 inactive。 + +## 备选方案 + +**冷会话 seed(composer-tab-geometry 模式)。** 否决:退出路径经 `commands.execute` 执行 `/plan off`,需要 live agent,而冷 seed 会话没有;`connectFreshWorkspace` 保留一个,与产品的用户路径一致。 + +**golden 固定绝对 bounding box。** 否决:chip 与 trigger 宽度依赖安装字体,绝对坐标会在平台间漂移而不反映行为变化。 + +**复用 plan-review fixture 形态(exit_plan_mode review takeover)。** 否决:takeover 会替换 composer 控制行,而被测表面正是控制行。 + +**chip 与/或模型 trigger 的容器查询 label 折叠。** 否决(作为修复):两个包(ui-plan、ui-model)需要各自标定阈值,且 chip 单独折叠为 icon-only 在报告视口下仍剩约 7px 重叠,除非 trigger 也折叠。换行是一个包中的一条规则,且在所有宽度下成立。 + +## 后果 + +任何改变控制行布局的后续改动——字体、间距、媒体查询或容器查询——一旦重新引入重叠或把 chip 沿任一轴移出视口,本测试即失败。测试在 replay/refresh 模式下无需 API key:Plan 模式经命令 handler 切换,不经模型回合;providers-only replay fixture(无录制脚本,跳过消费检查)挂载模型目录,使触发器渲染真实的长标签——正是使报告重叠可测量的宽度;测试在测量前断言该标签。golden 在 replay 与 record 模式下比较,在 refresh 模式下重写。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml index 612916a290..bca41cb330 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md -2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb -2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179 +2026-08-10-subagent-empty-terminal-message-output.md: 24bab01ad844a5b48e0bf6fe0fc54df6403f4bb7 +2026-08-10-subagent-empty-terminal-message-output.zh.md: ab3488806a4f7c019d783e563c79e06aeeb86f33 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md index 693013f681..24bab01ad8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -12,7 +12,7 @@ The agent loop appends an empty-content `assistant/message` when a `max-tokens` `dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason. -The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output. +The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message presents the optional safe Provider diagnostic owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md) after the stop-reason headline and appends the child's partial text afterward. The parent model receives the failure, separate infrastructure detail, and available assistant output without conflating them. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md index 64d61af21f..ab3488806a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -12,7 +12,7 @@ Status: implemented `dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。 -前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。 +前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后呈现由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全提供方诊断,再附上子 agent 的部分文本。父模型会同时收到失败、独立的基础设施说明与已有 assistant 输出,而且不会把它们混为一体。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index e8ece4d624..36fa7f01b2 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: fa3b4f570bfccdc849a38b3eda16c1c8dd7b1827 -2026-06-21-subagent-capability-seam.zh.md: b25fe64377f98af92dbccb87f755627926975ef2 +2026-06-21-subagent-capability-seam.md: bc84d88d701a5f3018bf00f0ecf8b60750917407 +2026-06-21-subagent-capability-seam.zh.md: baec829a1a01018b490992fa0982bb23e43ba740 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index fa3b4f570b..bc84d88d70 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -54,11 +54,11 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag ### Child isolation and the parent log -Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log. +Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output, or a failed result with optional provider diagnostic), while child steps and tool calls remain outside the parent log. ### Synchronous collect (first cut) -`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output, and independent result and disposal rejections retain both diagnostics. +`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output; they present the optional safe diagnostic owned by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) separately from partial assistant text. Independent result and disposal rejections remain independently observable. ### Provider selection is config, not model-facing diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index b25fe64377..baec829a1a 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -54,11 +54,11 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 子 agent 隔离与父日志 -每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外。 +每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出,或带可选提供方诊断的失败结果),而子 agent 的步骤和工具调用均留在父日志之外。 ### 同步收集(首版) -`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;结果与 dispose 的拒绝相互独立,且两项诊断信息都会保留。 +`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;它会把由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全诊断与部分 assistant 文本分开呈现。结果与 dispose 的拒绝仍可彼此独立地观察。 ### 提供方选择是配置,不面向模型 diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml index 0c0cf829a3..715da62619 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md -2026-07-08-background-subagent-tasks.md: 412ec61dcdecae1a273c5993d25a4a099a22e864 -2026-07-08-background-subagent-tasks.zh.md: 9d108440c992e150ed62edaef8bf860813a471b8 +2026-07-08-background-subagent-tasks.md: 4dcd961ee5a5db925f8f6ad83e97890eaedb8e63 +2026-07-08-background-subagent-tasks.zh.md: dd8ba47e018bdeeaffccca1fdaf52df75728057b diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md index 412ec61dcd..4dcd961ee5 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md @@ -8,7 +8,7 @@ English | [中文](2026-07-08-background-subagent-tasks.zh.md) The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially. -Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer and job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit. +Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer or safe failure detail plus job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit. ## Decision @@ -16,7 +16,7 @@ Each `dsh-tool-subagent` instance may expose `run_in_background`, controlled by Background subagents use the [generic background job runtime](../architecture/2026-06-20-generic-long-running-tool-runtime.md). Collection, listing, cancellation, completion notices, and prompt guidance come from `job_output`, `job_list`, and `job_kill`; there are no subagent-specific companion tools. -Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result, and always dispose the run before returning. +Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result with the optional safe diagnostic described by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md), and always dispose the run before returning. For a background call, the tool validates the parent and refuses an already-aborted execution signal before calling `ctx.jobs.start()`. The job runtime preflights the control API and owner cleanup before invoking the producer starter. That starter creates an independent `AbortController` and begins `ctx.subagents.start()`; after the id is returned, the tool-call signal no longer owns the child. @@ -24,7 +24,7 @@ The task registration maps the subagent seam as follows: - `kind` is `subagent`, `label` is the model-supplied description, and `owner` is the parent agent. - `cancel(reason?)` aborts the task-owned controller. The same signal covers pending provider startup and the published run's remaining work. -- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed`. Startup, result, and disposal failures become failed outcomes rather than rejected task promises. +- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed` with the Provider diagnostic when present. Startup, result, and disposal failures become failed outcomes rather than rejected task promises. - `readOutput` is absent. While live, `job_output` returns status only; after settlement, it returns final output idempotently. Intermediate child activity remains in the child session. ## Lifecycle diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md index 9d108440c9..dd8ba47e01 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md @@ -8,7 +8,7 @@ Status: implemented [subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。 -subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。 +subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案或安全失败说明,以及任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。 ## 决策 @@ -16,7 +16,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列 后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`job_output`、`job_list` 和 `job_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。 -前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。 +前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果,并在存在时附上由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)描述的可选安全诊断;而且始终在返回前释放该运行。 对于后台调用,工具会验证父级,并在调用 `ctx.jobs.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制 API 和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。 @@ -24,7 +24,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列 - `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。 - `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已发布 run 的剩余工作。 -- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 +- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`,并在存在时携带提供方诊断。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 - `readOutput` 不存在。任务存活期间,`job_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。 ## 生命周期 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index c70efe3121..c25841435c 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md -2026-07-24-provider-retry-policies.md: 1831ce6b96178d11e7c9927ceccbe07ea578cd2c -2026-07-24-provider-retry-policies.zh.md: 22f18badcb47e4ce086ff2c68c7011612b2eabba +2026-07-24-provider-retry-policies.md: 96979b219aebece96a1bcc09aa3dd572d2b9222d +2026-07-24-provider-retry-policies.zh.md: 02fe13e0ddead035ec750c027e889da08e2557ae diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index 1831ce6b96..96979b219a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -12,7 +12,7 @@ Provider policy must follow the request that actually failed, including a route ## Decision -Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. A provider without `retryPolicy` uses the normal defaults. +Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration, validates and resolves it, and exposes that resolved route policy through `providerRetryPolicy()`. Omission selects the shared core normal default of five retries for every composition, including Web, headless, and custom profiles. The effective policy remains route-owned registration state rather than a retry-executor setting. Layered settings may retain normal-only `maxRetries` or `retryableCodes` after changing `mode` to `always`; the resolver ignores those inactive fields while still rejecting unknown keys, and the registered always policy omits them. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. ```yaml providers: @@ -44,22 +44,28 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Alternatives considered -**One global `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. +**One retry-executor-level `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. Provider route policies remain authoritative, and the effective policy is captured only after routing selects a registration. **A separate exact-provider list on `dsh-llm-retry`** — rejected because it duplicates provider route names outside their owning adapter configuration and lets provider registration drift from recovery policy. **A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful. +**Adapter-specific omission defaults** — rejected because a shared budget would have to be repeated by every adapter family and every future adapter, making equivalent model routes behave differently depending on their implementation. + +**An LLM deployment-level default** — rejected because it introduces another configuration layer only to make Web differ from other compositions. The product default is uniform, while provider settings retain the existing per-route override. + +**Stamp five retries into profiles when the Web UI writes them** — rejected because existing profiles, settings written outside that UI, and non-Web compositions would retain the old value. + **Provider-SDK retries** — rejected because hidden attempts multiply agent-level budgets, cannot use the closed-step durability boundary, and may splice or discard streamed output without a reconstructable retry record. **Put the error into model context** — rejected because a transport or provider diagnostic is operational state, not conversation content. It can expose sensitive provider details and changes the retried request instead of repeating the failed request. ## Verification -Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove explicit profile policies reach registration, prove omission resolves to five retries, and retain the serving policy across in-flight route replacement. LLM service tests prove adapter policies are captured and omission uses the shared five-retry behavior. Resolver tests prove always mode ignores retained normal-only fields but returns a pure always policy. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins omitted DeepSeek and pi-ai policies at five retries, then proves settings can write `{ mode: 'always', maxRetries: 5 }` and obtain a pure always policy. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. ## Consequences -Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract. +Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Any model route using omission defaults may spend up to three more requests and their backoff time than under the former two-retry default, in exchange for recovering from longer transient outages. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract. This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md). diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index 22f18badcb..02fe13e0dd 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该特定提供方路由注册时捕获策略。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。未配置 `retryPolicy` 的提供方使用 normal 默认值。 +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`,对它进行校验与解析,并通过 `providerRetryPolicy()` 公开解析后的路由策略。省略配置时,Web、headless 与自定义 profile 等所有组合都使用核心共享的 normal 模式五次重试默认值。有效策略仍然是路由拥有的注册状态,而不是重试执行器设置。分层 settings 在把 `mode` 改为 `always` 后可能保留仅属于 normal 的 `maxRetries` 或 `retryableCodes`;解析器会忽略这些未启用字段,同时仍拒绝未知键,注册后的 always 策略也不包含它们。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。 ```yaml providers: @@ -44,22 +44,28 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 曾考虑的替代方案 -**单一全局 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。 +**重试执行器级的单一 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。提供方路由策略仍然权威,而且只有在路由选定注册后才捕获有效策略。 **在 `dsh-llm-retry` 上维护单独的指定提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 **设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的约定,并把任意选取的运维上限序列化成看似有意义的数值。 +**按适配器设置不同的省略默认值**:不予采纳,因为共享预算必须在每种适配器族以及未来的每个适配器中重复配置,同等模型路由也会因实现不同而表现不同。 + +**LLM 部署级默认值**:不予采纳,因为这只为区分 Web 与其他组合增加了一层配置。产品默认值保持统一,提供方 settings 则保留既有的逐路由覆盖能力。 + +**在 Web UI 写入 profile 时把五次重试写死进去**:不予采纳,因为现有 profile、从该 UI 之外写入的 settings 以及非 Web 组合仍会保留旧值。 + **使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭步骤的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。 **把错误放入模型上下文**:不予采纳,因为传输或提供方诊断信息属于运维状态,而非对话内容。它可能暴露敏感的提供方细节,并会改变重试请求,无法重复原本失败的请求。 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明显式 profile 策略抵达注册流程,证明省略配置会解析为五次重试,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。LLM 服务测试会证明适配器策略被捕获,且省略配置使用共享的五次重试行为。解析器测试会证明 always 模式忽略残留的 normal 专属字段,但返回纯 always 策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照会把省略配置的 DeepSeek 与 pi-ai 策略固定为五次重试,再证明 settings 可以写入 `{ mode: 'always', maxRetries: 5 }` 并得到纯 always 策略。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 ## 后果 -normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。 +normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。任何使用省略默认值的模型路由相比原先的两次重试默认值,最多会多花费三次请求及其退避时间,以此换取从更长短暂故障中恢复的能力。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。 本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭步骤恢复、单次可见适配器尝试、结构化失败与持久化状态设计。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 5ef2099535..d48293405c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: 823d10a723af70ec4ff51018b8b86198db0f5c29 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 7e8d79c23c4b1489bfd818c90558f36509635486 +2026-07-30-deepseek-onboarding-credential-setup.md: 87533e7a55f9b1f05f6a4ba58c3c9888780c158c +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 575d8232b4837b57d9508ed613530606c05db1f7 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 823d10a723..87533e7a55 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. +**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering. **The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md). diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 7e8d79c23c..575d8232b4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)持有这次 settings 读取及其失效顺序。 **设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index ea66531864..9aa0372a26 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 072f91b730cfc9eaeead7701d2b20d12b443acb3 -2026-07-31-browser-derived-initial-locale.zh.md: 97f0f0007474c21fb618a085b506bc919586f624 +2026-07-31-browser-derived-initial-locale.md: 66fd56327aeb4463bfb8f6426ce7f7962d339782 +2026-07-31-browser-derived-initial-locale.zh.md: 721a785aa476951e7254c50230ddc092b9f8b211 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 072f91b730..66fd56327a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -8,29 +8,39 @@ English | [中文](2026-07-31-browser-derived-initial-locale.zh.md) The Settings Language row opened every first visit in Chinese: `LocaleRuntime` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen. +Reading the browser fixed the readers whose browser names a language this app ships, but left the residual case wrong: a browser asking for neither `zh` nor `en` (`fr`, `de`) still fell back to `zh`. Those readers are the least likely to read Chinese. + ## Decision -**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. +**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. + +**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`. The residual case points at English rather than zh because a browser naming neither shipped language is the reader least likely to read Chinese. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. -**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. +**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language, so gating on `navigator` would let a node boot of the client tree resolve to the machine's language instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. **An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. -**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature. +**`` follows the resolved locale, and the served markup cannot.** `apps/web/index.html` is one static file serving every visitor, so whatever it declares is wrong for somebody: resolution happens in the client, after the document is parsed. The locale plugin therefore sets `document.documentElement.lang` from the active locale — once at activation, because detection or an adopted Host preference may already disagree with the markup, and again on every switch. The markup declares the product default (`en`) so the pre-boot document is not actively misleading. Assistive technology and browser features (pronunciation rules, translation offers, font fallback, spell check) read this attribute, so a stale value misreports the document language rather than merely looking untidy. The attribute carries a BCP 47 tag rather than the app's locale id: `zh` alone leaves the script ambiguous, so the shipped Chinese copy declares `zh-CN`. + +**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback. ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading. - **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this". - **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it. -- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **A cordis config key for the fallback locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead. +- **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice. - **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end. +- **Serving `` per request, or leaving the static attribute alone**: computing it server-side would need the request's `Accept-Language` to re-derive what the client resolves anyway, duplicating the rule in two places and still losing to a stored preference the server does not read. Leaving it static is what made the attribute permanently wrong for one language or the other. Setting it from the resolved locale keeps one source of truth. ## Consequences -- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. -- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen". -- Tests that construct a `LocaleRuntime` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all). +- A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. +- Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on. +- `` now reports the language on screen in both directions, which closes [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160). A client that never activates the locale plugin keeps the served default, so the attribute degrades to the old static behavior rather than to a blank value. +- Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead. - Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 97f0f00074..721a785aa4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -8,29 +8,39 @@ Status: implemented 设置里的语言行在每一次首访时都以中文开场:`LocaleRuntime` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。 +读取浏览器修好了那些浏览器声明了本应用所提供语言的读者,但残余情形依然是错的:既不请求 `zh` 也不请求 `en` 的浏览器(`fr`、`de`)仍会回落到 `zh`。这些读者恰恰最不可能阅读中文。 + ## Decision -**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 +**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 + +**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`。残余情形指向英文而非 `zh`,是因为一个声明了本应用都不支持的语言的浏览器,其读者最不可能读中文。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 -**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 +**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言,因此以 `navigator` 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 **显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 -**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。 +**`` 跟随解析出的 locale,而所服务的 markup 做不到这一点。** `apps/web/index.html` 是一份静态文件,服务所有访问者,因此它声明什么都必然对某些人是错的:解析发生在客户端,在文档被解析之后。于是由 locale 插件依据当前 locale 设置 `document.documentElement.lang`——激活时设置一次,因为探测结果或已采纳的 Host 偏好可能已与 markup 不一致;此后每次切换再设置一次。markup 声明产品默认值(`en`),使启动前的文档不至于主动误导。无障碍技术与浏览器功能(发音规则、翻译提示、字体回退、拼写检查)都读取该属性,因此陈旧的值是在误报文档语言,而不只是看起来不整齐。该属性承载 BCP 47 标签而非应用内部的 locale id:单独的 `zh` 会使文字(script)含义不明,因此已提供的中文文案声明 `zh-CN`。 + +**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。 ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。 - **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。 - **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。 -- **为默认 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **为回落 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。 +- **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。 - **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。 +- **按请求服务 ``,或干脆不管这个静态属性**:在服务端计算它需要用请求的 `Accept-Language` 去重新推导客户端本就会解析的结果,使同一条规则在两处重复,而且仍会输给服务端并不读取的存储偏好。放任其保持静态,正是该属性对某一种语言永远错误的原因。依据解析出的 locale 来设置,可保持单一真源。 ## Consequences -- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 -- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。 -- 在 jsdom 下构造 `LocaleRuntime` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。 +- 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 +- 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。 +- `` 现在在两个方向上都如实报告屏幕上的语言,这也关闭了 [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160)。若某个客户端从未激活 locale 插件,则保留所服务的默认值,因此该属性退化为旧的静态行为,而不会退化为空值。 +- 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。 - 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml index d34847dc00..53d0c54764 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md -2026-08-03-web-search-source-scroll.md: 3402f519e1974b99e1f5a87dcd53b4d94a1a8374 -2026-08-03-web-search-source-scroll.zh.md: 8ac1158d054f739bb1f76c87570ac1e75b77e05d +2026-08-03-web-search-source-scroll.md: 6fe532e2a2989e834b926cf48d531ae60a32f58b +2026-08-03-web-search-source-scroll.zh.md: bc1abb5215c618809e79f56d1f9bd6c1ee9dbf15 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md index 3402f519e1..6fe532e2a2 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md @@ -8,13 +8,13 @@ English | [中文](2026-08-03-web-search-source-scroll.zh.md) The `web_search` result card (`WebBlock`, `packages/client/ui-primitives/src/WebBlock.tsx`) rendered its source list with a head/tail collapse: past a `maxSources` count (16 in the details panel, 8 in the chat row via `CHAT_WEB_MAX_SOURCES`) it drew the first `ceil(max/2)` sources, an `… 其余 N 条来源` expand button, then the last `max - ceil(max/2)`, mirroring `TerminalBlock`'s output cap. A user reading the card saw `来源列表已截断` and assumed the frontend had dropped sources it was holding. -It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts the provider's sources to the tool's `searchMaxResults` bound (default 8) and sets `truncated`, and that one capped list feeds both the model-facing render text and the card's `presentationMeta`. The card never holds more sources than that one cut produced. So the collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything. +It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts each provider result to the tool's `searchMaxResults` bound (default 8); a multi-query call then deduplicates, interleaves, and caps the combined sources at the same bound. The final capped list feeds both the model-facing render text and the card's `presentationMeta`, so the card never holds more sources than the tool returned. The collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything. ## Decision `WebBlock`'s search arm renders every source it receives in one `
    `, with no head/tail slicing, no expand button, and no `maxSources` prop. `.sources` (`WebBlock.module.css`) gets a fixed `max-height` and `overflow-y: auto`, so a list longer than the card height scrolls in place rather than growing the card or hiding rows. The height is a design constant of the card geometry, so it lives in CSS, not a plugin config field. -The model side is unchanged: the seam still caps sources at `searchMaxResults`, the model-facing render text is untouched, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the list the seam produced, in full and scrollable, instead of collapsing its middle. +The model side remains capped at `searchMaxResults`: the seam caps each provider result, the multi-query consumer caps a combined list, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the final tool source list in full and scrollable, instead of collapsing its middle. That list is the one the model reads as long as nothing downstream of the tool rewrites the result content alone. A deployment mounting `dsh-spill-policy` breaks that correspondence for an oversized result: `tools/post-execute` replaces the model-facing `content` with a preview plus a spill locator and leaves `presentationMeta` whole, so the card still draws every source while the model reads a bounded excerpt. The card's contract is therefore the view it receives, not the model's context. @@ -36,11 +36,11 @@ Every source the tool returned is always in the DOM, so no source the view carri ## Testing -`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `
  1. ` with no `[aria-expanded]` and no ` ) } -function PhaseSection({ phase, navigable, openSession, t }: { +function PhaseSection({ + contentRef, onContentBlur, onToggle, open, pendingCleanCollapse, + phase, navigable, openSession, t, +}: { + readonly contentRef: (element: HTMLDivElement | null) => void + readonly onContentBlur: (event: FocusEvent) => void + readonly onToggle: () => void + readonly open: boolean + readonly pendingCleanCollapse: boolean readonly phase: WorkflowRunPhaseData readonly navigable: readonly SessionId[] readonly openSession: WorkflowRunInjected['openSession'] readonly t: WorkflowRunPanelProps['t'] }) { return ( - } - title={readablePhase(phase.phase, t)} - cleanCycleKey={phase.members.length} - requiresExpansion={phaseRequiresExpansion(phase)} - expandOnRowClick - previewChevron={false} - keepContentWhenOpen +
    - - {memberCount(phase.members.length, t)} - {phaseStatusSummary(phase.members, t)} - - )} + onMouseDownCapture={pendingCleanCollapse ? preventPendingHeaderFocus : undefined} > -
    - {phase.members.map(member => ( - - ))} -
    - + } + title={readablePhase(phase.phase, t)} + open={open} + onToggle={onToggle} + expandOnRowClick + previewChevron={false} + keepContentWhenOpen + rowClassName={css.phaseHeader} + leadingClassName={css.phaseLeading} + titleClassName={css.phaseTitle} + collapsedContent={( + <> + + {memberCount(phase.members.length, t)} + {phaseStatusSummary(phase.members, t)} + + )} + > +
    + {phase.members.map(member => ( + + ))} +
    +
    +
    ) } /** Render one durable workflow run with status-driven run and phase disclosure. */ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { - const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) - const requiresExpansion = node.data.status !== 'completed' - || node.data.phases.some(phaseRequiresExpansion) + const phaseFacts = useMemo(() => node.data.phases.map(phase => ( + [phase.key, phaseDisclosureFacts(phase)] as const + )), [node.data.phases]) + const runFacts = useMemo( + () => runDisclosureFacts(node.data.status, phaseFacts), + [node.data.status, phaseFacts], + ) + const totalMembers = runFacts.activityCount + const [disclosures, setDisclosures] = useState(() => ({ + run: initialDisclosureState(runFacts), + phases: new Map(phaseFacts.map(([key, facts]) => [key, initialDisclosureState(facts)])), + })) + const runContentRef = useRef(null) + const phaseContentRefs = useRef(new Map()) const navigable = useSessions( sessions => navigableMembers(sessions, node.data.phases, sessionId), shallowEqual, ) + + // Outer hiding unmounts Phase content without a dependable blur event, so this edge settles deferred closes. + useLayoutEffect(() => { + setDisclosures((current) => { + const phases = new Map() + let phasesChanged = current.phases.size !== phaseFacts.length + let phaseStartedCycle = false + for (const [key, facts] of phaseFacts) { + const previous = current.phases.get(key) + const next = previous === undefined + ? initialDisclosureState(facts) + : advanceDisclosureState(previous, facts, focusIsWithin(phaseContentRefs.current.get(key))) + phases.set(key, next) + if (next !== previous) phasesChanged = true + if (previous?.mode === 'clean' + && (facts.mode !== 'clean' || facts.activityCount !== previous.activityCount)) { + phaseStartedCycle = true + } + } + const advancedRun = advanceDisclosureState( + current.run, + runFacts, + focusIsWithin(runContentRef.current), + ) + const run = phaseStartedCycle && runFacts.mode !== 'clean' && !advancedRun.open + ? { ...advancedRun, open: true, pendingCleanCollapse: false } + : advancedRun + return run !== current.run || phasesChanged ? { run, phases } : current + }) + }, [disclosures.run.open, phaseFacts, runFacts]) + + const toggleRun = (): void => { + setDisclosures(current => ({ + ...current, + run: { + ...current.run, + open: !current.run.open, + pendingCleanCollapse: false, + }, + })) + } + const togglePhase = (key: string): void => { + setDisclosures((current) => { + const phases = new Map(current.phases) + const phase = existingPhaseState(phases, key) + phases.set(key, { + ...phase, + open: !phase.open, + pendingCleanCollapse: false, + }) + return { ...current, phases } + }) + } + const settleRunBlur = (event: FocusEvent): void => { + if (event.currentTarget.contains(event.relatedTarget)) return + setDisclosures((current) => { + const run = collapsePending(current.run) + return run === current.run ? current : { ...current, run } + }) + } + const settlePhaseBlur = (key: string, event: FocusEvent): void => { + if (event.currentTarget.contains(event.relatedTarget)) return + setDisclosures((current) => { + const phase = existingPhaseState(current.phases, key) + const next = collapsePending(phase) + if (next === phase) return current + const phases = new Map(current.phases) + phases.set(key, next) + return { ...current, phases } + }) + } + return ( -
    +
    -
    +
    {node.data.phases.length === 0 ? {t('run.empty')} - : node.data.phases.map(phase => ( - - ))} + : node.data.phases.map((phase) => { + const facts = phaseDisclosureFacts(phase) + const disclosure = disclosures.phases.get(phase.key) ?? initialDisclosureState(facts) + return ( + { + if (element === null) phaseContentRefs.current.delete(phase.key) + else phaseContentRefs.current.set(phase.key, element) + }} + onContentBlur={(event) => { settlePhaseBlur(phase.key, event) }} + onToggle={() => { togglePhase(phase.key) }} + open={disclosure.open} + pendingCleanCollapse={disclosure.pendingCleanCollapse} + phase={phase} + navigable={navigable} + openSession={openSession} + t={t} + /> + ) + })}
    diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 38c7d15c70..248f932639 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -27,6 +27,7 @@ afterEach(cleanup) const PARENT_ID = 'parent' as SessionId const CHILD_ID = 'child-1' as SessionId +const SECOND_ID = 'child-2' as SessionId interface ChatSnapshot { readonly nodes: ReadonlyMap @@ -301,28 +302,62 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi } describe('WorkflowRunPanel', () => { - it('forces running run and phase content open without false disclosure controls', () => { - const view = render( { + const running: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })], - })} />) - expect(screen.getByText('worker')).toBeTruthy() - expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() - expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() - const rows = [...view.container.querySelectorAll('[data-disclosure-row]')] - expect(rows).toHaveLength(2) - for (const row of rows) { - expect(row.getAttribute('role')).toBeNull() - expect(row.getAttribute('tabindex')).toBeNull() - expect(row.getAttribute('aria-expanded')).toBeNull() - expect(row.getAttribute('data-expandable')).toBeNull() } + const view = render() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + const phaseHeader = screen.getByRole('button', { name: /Research/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('worker')).toBeTruthy() + + fireEvent.click(phaseHeader) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('worker')).toBeNull() + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + + view.rerender() + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() + fireEvent.keyDown(runHeader, { key: 'ArrowDown' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(runHeader, { key: ' ' }) + const updatedPhase = screen.getByRole('button', { name: /Research/ }) + expect(updatedPhase.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('运行中 2')).toBeTruthy() + fireEvent.keyDown(updatedPhase, { key: 'Enter' }) + expect(screen.getByText('worker')).toBeTruthy() + expect(screen.getByText('second')).toBeTruthy() + + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() + fireEvent.keyDown(runHeader, { key: ' ' }) + expect(screen.getByRole('button', { name: /Research/ }).getAttribute('aria-expanded')).toBe('true') }) - it('folds each clean transition once and preserves review choices until activity returns', () => { + it('folds each normal completion once and opens a new same-key activity cycle', () => { const running: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase()], } const view = render() + const runningPhase = screen.getByRole('button', { name: /未分阶段/ }) + fireEvent.click(runningPhase) + fireEvent.keyDown(runningPhase, { key: 'Enter' }) + expect(screen.getByText('worker')).toBeTruthy() + const phaseCompleted: WorkflowRunChatData = { ...running, phases: [phase({ @@ -338,27 +373,8 @@ describe('WorkflowRunPanel', () => { fireEvent.click(phaseHeader) expect(screen.getByText('done')).toBeTruthy() - const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' } - view.rerender() - const runHeader = screen.getByRole('button', { name: /^audit/ }) - expect(runHeader.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('未分阶段')).toBeNull() - fireEvent.keyDown(runHeader, { key: 'ArrowDown' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(runHeader, { key: 'Enter' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('true') - const completedPhase = screen.getByRole('button', { name: /未分阶段/ }) - fireEvent.keyDown(completedPhase, { key: 'Enter' }) - expect(screen.getByText('done')).toBeTruthy() - fireEvent.keyDown(runHeader, { key: ' ' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(runHeader, { key: ' ' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) - expect(screen.getByText('done')).toBeTruthy() - const cleanUpdate: WorkflowRunChatData = { - ...completed, + ...phaseCompleted, phases: [phase({ members: [{ seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed', @@ -368,13 +384,44 @@ describe('WorkflowRunPanel', () => { view.rerender() expect(screen.getByText('reviewed')).toBeTruthy() - view.rerender() - expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() - expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() - expect(screen.getByText('worker')).toBeTruthy() - view.rerender() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + const runHeader = screen.getByRole('button', { name: /^audit/ }) + fireEvent.click(runHeader) + const renewed: WorkflowRunChatData = { + name: 'audit', status: 'running', + phases: [phase({ + members: [ + { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' }, + { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'running' }, + ], + })], + } + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('new')).toBeTruthy() + + const renewedPhaseCompleted: WorkflowRunChatData = { + ...renewed, + phases: [phase({ + members: [ + { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' }, + { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'completed' }, + ], + })], + } + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + + view.rerender() expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('未分阶段')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') }) it('refolds a phase when a complete activity cycle arrives as one clean update', () => { @@ -388,21 +435,59 @@ describe('WorkflowRunPanel', () => { const phaseView = render() fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) expect(screen.getByText('first')).toBeTruthy() + const runHeader = screen.getByRole('button', { name: /^phase-cycle/ }) + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') phaseView.rerender() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText('first')).toBeNull() expect(screen.queryByText('second')).toBeNull() + + phaseView.rerender() + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + }) + + it('initializes a newly observed phase before it becomes interactive', () => { + const running: WorkflowRunChatData = { + name: 'dynamic-phase', status: 'running', + phases: [phase({ key: 'research', phase: 'Research' })], + } + const view = render() + view.rerender() + const build = screen.getByRole('button', { name: /Build/ }) + expect(build.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(build) + expect(build.getAttribute('aria-expanded')).toBe('false') }) it('derives the zero-member running and completed states from the current run status', () => { const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] } const view = render() - expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull() + expect(screen.getByRole('button', { name: /^empty/ }).getAttribute('aria-expanded')).toBe('true') expect(screen.getByText('没有启动成员')).toBeTruthy() view.rerender() const header = screen.getByRole('button', { name: /^empty/ }) @@ -413,31 +498,60 @@ describe('WorkflowRunPanel', () => { }) it.each(['failed', 'cancelled', 'interrupted'] as const)( - 'bubbles a %s member to the run and keeps a matching run outcome open', + 'initializes %s attention as an expanded disclosure that remains manually collapsible', (status) => { - const memberView = render() - expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull() - expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + const runHeader = screen.getByRole('button', { name: /^member-outcome/ }) + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') expect(screen.getByText(status)).toBeTruthy() - memberView.unmount() - - render() - expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull() - expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('done')).toBeNull() + fireEvent.click(phaseHeader) + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') }, ) + it('opens the first abnormal edge once and preserves later abnormal choices', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + + const failed: WorkflowRunChatData = { + name: 'audit', status: 'running', + phases: [phase({ + members: [{ seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' }], + })], + } + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy() + }) + it('keeps clean sibling phases independent and preserves empty versus absent names', () => { render( { }] }), ], })} />) - expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') const cleanPhase = screen.getByRole('button', { name: /空阶段名/ }) expect(cleanPhase.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + const activePhase = screen.getByRole('button', { name: /未分阶段/ }) + expect(activePhase.getAttribute('aria-expanded')).toBe('true') expect(screen.queryByText('空成员名')).toBeNull() expect(screen.getByText('second')).toBeTruthy() fireEvent.click(cleanPhase) expect(screen.getByText('空成员名')).toBeTruthy() expect(screen.getByText('second')).toBeTruthy() - fireEvent.click(cleanPhase) + fireEvent.click(activePhase) + expect(screen.queryByText('second')).toBeNull() + expect(screen.getByText('空成员名')).toBeTruthy() + fireEvent.click(runHeader) + fireEvent.click(runHeader) + expect(screen.getByRole('button', { name: /空阶段名/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) expect(screen.queryByText('空成员名')).toBeNull() - expect(screen.getByText('second')).toBeTruthy() }) it('renders mixed and interrupted aggregate status while attention stays visible', () => { @@ -496,6 +618,170 @@ describe('WorkflowRunPanel', () => { expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2) }) + it('defers normal completion collapse until focused member content loses focus', () => { + const sessions = listState({ + ids: [PARENT_ID, CHILD_ID, SECOND_ID], + byId: { + ...listState().byId, + [SECOND_ID]: { + id: SECOND_ID, displayTitle: 'second', parentId: PARENT_ID, origin: 'subagent', + running: true, blank: false, updatedAt: 0, + }, + }, + }) + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase({ + members: [ + { seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' }, + { seq: 2, label: 'second', childId: SECOND_ID, status: 'running' }, + ], + })], + } + const view = render() + const member = screen.getByRole('button', { name: '打开 worker' }) + const second = screen.getByRole('button', { name: '打开 second' }) + const runHeader = screen.getByRole('button', { name: /^audit/ }) + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + member.focus() + expect(document.activeElement).toBe(member) + fireEvent.blur(member, { relatedTarget: second }) + second.focus() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + + const outside = document.createElement('button') + document.body.append(outside) + fireEvent.blur(second, { relatedTarget: outside }) + outside.focus() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + member.focus() + + view.rerender() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + const retained = screen.getByRole('button', { name: 'worker' }) + expect(retained.getAttribute('aria-disabled')).toBe('true') + expect(document.activeElement).toBe(retained) + + fireEvent.blur(retained, { relatedTarget: outside }) + outside.focus() + expect(document.activeElement).toBe(outside) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(runHeader) + const completedPhase = screen.getByRole('button', { name: /未分阶段/ }) + expect(completedPhase.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(completedPhase) + expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() + expect(screen.getByText('worker')).toBeTruthy() + outside.remove() + }) + + it('handles a pointer blur and header click as one pending-completion close', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + const member = screen.getByRole('button', { name: '打开 worker' }) + member.focus() + view.rerender() + const retained = screen.getByRole('button', { name: 'worker' }) + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + const runHeader = screen.getByRole('button', { name: /^audit/ }) + + expect(fireEvent.mouseDown(retained)).toBe(true) + expect(document.activeElement).toBe(retained) + expect(fireEvent.mouseDown(phaseHeader)).toBe(false) + fireEvent.click(phaseHeader) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + + expect(fireEvent.mouseDown(runHeader)).toBe(false) + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + }) + + it('settles pending completion when keyboard focus moves from content to its header', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + const member = screen.getByRole('button', { name: '打开 worker' }) + member.focus() + view.rerender() + const retained = screen.getByRole('button', { name: 'worker' }) + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + const runHeader = screen.getByRole('button', { name: /^audit/ }) + fireEvent.blur(retained, { relatedTarget: phaseHeader }) + phaseHeader.focus() + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + + view.rerender() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + fireEvent.blur(phaseHeader, { relatedTarget: runHeader }) + runHeader.focus() + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + }) + + it('settles a deferred phase close when the user hides the outer run', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + const member = screen.getByRole('button', { name: '打开 worker' }) + member.focus() + view.rerender() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(runHeader) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('reinitializes manual choices from durable facts after a renderer remount', () => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + view.unmount() + render() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + }) + it('opens only a running ordinary-list subagent proven to have this parent', () => { const data: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase()], @@ -506,6 +792,16 @@ describe('WorkflowRunPanel', () => { expect(openSession).toHaveBeenCalledWith('child-1') }) + it('promotes a running member when its ordinary Session row arrives', () => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() + view.rerender() + expect(screen.getByRole('button', { name: '打开 worker' })).toBeTruthy() + }) + it.each([ ['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'], ['remote row', listState({ byId: { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 016af313f8..abba4371c2 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -2,16 +2,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') - async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() @@ -37,6 +32,10 @@ async function bench() { } as never) ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename, diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 171f7322f0..fe37fef48a 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -3268,7 +3268,7 @@ describe('dynamic nested workspace context injection', () => { try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule') - await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule') + await write(join(root, 'pkg/CLAUDE.md'), 'initial divergent nested rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) @@ -3280,7 +3280,7 @@ describe('dynamic nested workspace context injection', () => { }) const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(firstText).toContain('canonical nested rule') - expect(firstText).toContain('divergent nested rule') + expect(firstText).toContain('initial divergent nested rule') await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') await ctx.tools.execute({ diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 58bbbdee3f..c6539aa46a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'locale', summary: 'Dictionary registry plus locale preference.', - description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s zh fallback -> the shared common namespace (active, then zh) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', + description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s en fallback -> the shared common namespace (active, then en) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', methods: [ { signature: 'getLocale(): LocaleSnapshot', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 10609b5f2d..f6f998a383 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4241,7 +4241,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentResult', - declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', + declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly diagnostic?: string;\n readonly stopReason: SubagentStopReason;\n}', }, { name: 'SubagentRun', diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index b24bc8ceb1..783e4e1e6f 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -188,7 +188,10 @@ describe('real Loader composition', () => { // behavior, not the chooser's); await that debounced write so it cannot // race the temp-dir removal, and pin that the persisted row is the // chooser itself — the resolved backend still never reaches the file. - await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') + await expect.poll( + async () => await readFile(configPath, 'utf8'), + { timeout: 15_000 }, + ).toContain('disabled: true') expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE) }) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 4ac4cf4ed0..cf54a96ec7 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: af89e7ee3bab6ec209349d047f81308eb6e87cef -README.zh.md: e9ce3206027ffeee9bc49eb7a2ed76ddcbe7bfc8 +README.md: 9bb28e6876b82c521341769123a8b2d0e5d98e09 +README.zh.md: 21c55cfa32bf68e0cac4c0bd72c94c86d955fc00 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index af89e7ee3b..9bb28e6876 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,7 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - retryPolicy: # optional; omission uses bounded normal defaults + retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: initialDelayMs: 500 @@ -35,7 +35,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index e9ce320602..21c55cfa32 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -20,7 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - retryPolicy: # optional; omission uses bounded normal defaults + retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: initialDelayMs: 500 @@ -35,7 +35,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index a7b22ddd88..cbaa0c13b6 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -76,7 +76,7 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index d4ac70ff20..694da1fb3c 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 5dbcb905451f72a700dd09b4052dcb2f88e858c9 -README.zh.md: 217244c7b4d7ecd5aa88427990feb58c96e5acaf +README.md: f696b6bee50b844bfbc6bab7f9def0e785d450c9 +README.zh.md: cace9d1fdfd85a559674b121b79b3dbb86b4337b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 5dbcb90545..f696b6bee5 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support ## Config -Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. +Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Each profile may set a `retryPolicy`; omission uses normal mode with five retries. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. ```yaml - id: llm @@ -113,7 +113,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -202,4 +202,4 @@ Recorded response content appends to the next request and does not invalidate it - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. -- **Retry policy is provider-owned, not an SDK retry** — each provider profile may configure nested `retryPolicy`, which `dsh-llm-retry` executes at the agent failed-step extension point; pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. +- **Retry policy is provider-owned, not an SDK retry** — each provider profile may supply nested `retryPolicy`; omission resolves to normal mode with five retries, and the effective route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 217244c7b4..cace9d1fdf 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,7 +8,7 @@ ## 配置 -按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。每个 profile 都可以设置 `retryPolicy`;省略时使用 normal 模式并重试五次。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm @@ -114,7 +114,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -203,4 +203,4 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 -- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤扩展点上执行;pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 +- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以提供嵌套的 `retryPolicy`;省略时解析为 normal 模式并重试五次,`dsh-llm-retry` 会在 agent 的失败步骤扩展点上执行有效路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 95fce2a31f..d1e1f697a9 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -154,7 +154,7 @@ export interface PiAiProviderProfile { * requests instead of being rejected by a request-size cap. */ maxRequestImageBytes?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 4f4d1aaf21..d45a2e3089 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -392,7 +392,7 @@ describe('provider profile lifecycle', () => { }) expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index fd2395c33f..2c468f9bc1 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md -README.md: 0a907b5505650f7c2cd5e9933750be3701f8e34b -README.zh.md: 024545e1ed52bb8d4aeac8114ffafc26a04a2ddb +README.md: 1dd530051e387230510414e35701db10c7c7c063 +README.zh.md: 52badf6a6e7d2c2571fa951865f6b4b3f08ae4ad diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 0a907b5505..1dd530051e 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Function plugin that applies exact-provider retry policy through the agent loop's closed-step `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn. -Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence. +Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: five retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence. Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 024545e1ed..52badf6a6e 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -4,7 +4,7 @@ 一个函数插件,通过 agent loop(智能体循环)在已关闭步骤上触发的 `agent/request-error` waterfall(瀑布式事件)应用确切提供方重试策略。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。 -每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留当时为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、符合条件的 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。 +每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留当时为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试五次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、符合条件的 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。 diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index fce8fa059b..0700b14362 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: fb6bd84240b41dd730d45b3eb34c35827dc4c991 -README.zh.md: 5c22767a7c654972cbf614505382fa4755d7d318 +README.md: e6b3c4924ad4e7cf115abdbfb38d22d0f524e377 +README.zh.md: 51f034af632c19ca91c1d2f91a7c9c22714870e0 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index fb6bd84240..e6b3c4924a 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -8,6 +8,10 @@ Provider-neutral LLM vocabulary and abstract service. This package defines the c An adapter registry plus a single streaming call API, interceptable via a waterfall event. +### Retry policy + +Each provider adapter supplies its resolved route policy. Omitting provider configuration uses bounded normal mode with five retries after the first request. Layered configuration may retain `maxRetries` or `retryableCodes` after changing `mode` to `always`; resolution ignores those inactive normal-mode fields and captures a pure always policy. This service stores the effective policy but does not execute retries. + ### Public API - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 5c22767a7c..51f034af63 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -8,6 +8,10 @@ 一个适配器注册表加单一流式调用接口,可通过 waterfall(瀑布式事件)拦截。 +### 重试策略 + +每个提供方适配器都会提供解析后的路由策略。省略提供方配置时使用有界 normal mode,在首次请求后最多重试五次。分层配置把 `mode` 改为 `always` 后可能残留 `maxRetries` 或 `retryableCodes`;解析过程会忽略这些不再生效的 normal-mode 字段,并捕获纯 always 策略。本服务存储有效策略,但不执行重试。 + ### 公开 API - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的句柄还提供 `replace(providers)`:候选路由集合会在注册状态发生任何变化前完成整体验证,因此与另一适配器发生冲突时,当前路由仍保持注册并继续提供服务。替换会在一次同步操作中完成,不会出现可观察的空档。`replace([])` 合法,表示保留注册但不持有任何路由;初始注册则不得为空。 diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts index ad9c7af65c..f6e6175cb9 100644 --- a/packages/llm/llm/src/retry-policy.ts +++ b/packages/llm/llm/src/retry-policy.ts @@ -11,7 +11,7 @@ import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { EMPTY_RESPONSE_CODE } from './error.ts' -const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_MAX_RETRIES = 5 const DEFAULT_INITIAL_DELAY_MS = 500 const DEFAULT_MAX_DELAY_MS = 10_000 const DEFAULT_JITTER_RATIO = 0.1 @@ -37,7 +37,7 @@ export interface BackoffConfig { export interface NormalRetryPolicyConfig { /** Retry only configured transient failure codes. */ mode: 'normal' - /** Maximum eligible retries after the first request (default 2). */ + /** Maximum eligible retries after the first request (default 5). */ maxRetries?: number /** Stable failure codes eligible for this policy. */ retryableCodes?: string[] @@ -105,7 +105,11 @@ export const RetryPolicySchema: z = z.union([ const NORMAL_POLICY_KEYS: ReadonlySet = new Set([ 'mode', 'maxRetries', 'retryableCodes', 'backoff', ]) -const ALWAYS_POLICY_KEYS: ReadonlySet = new Set(['mode', 'backoff']) +// Layered configuration can retain normal-only fields after switching modes; +// always mode ignores those inactive values while still rejecting unknown keys. +const ALWAYS_POLICY_KEYS: ReadonlySet = new Set([ + 'mode', 'maxRetries', 'retryableCodes', 'backoff', +]) const BACKOFF_KEYS: ReadonlySet = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']) function validateKeys(value: object, allowed: ReadonlySet, path: string): void { diff --git a/packages/llm/llm/tests/retry-policy.spec.ts b/packages/llm/llm/tests/retry-policy.spec.ts index 1860625147..cc7ebb8fa7 100644 --- a/packages/llm/llm/tests/retry-policy.spec.ts +++ b/packages/llm/llm/tests/retry-policy.spec.ts @@ -12,7 +12,7 @@ describe('provider retry policy', () => { expect(policy).toEqual({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'], initialDelayMs: 500, maxDelayMs: 10_000, @@ -59,6 +59,21 @@ describe('provider retry policy', () => { expect(RetryPolicySchema).toBeDefined() }) + it('ignores normal-only fields retained after switching to always mode', () => { + const layered = { + mode: 'always', + maxRetries: 5, + retryableCodes: ['SERVER'], + } as unknown as RetryPolicyConfig + + expect(resolveRetryPolicy(layered, 'provider.retryPolicy')).toEqual({ + mode: 'always', + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + }) + it.each([ [{ mode: 'normal', maxRetries: -1 }, /maxRetries/], [{ mode: 'normal', maxRetries: 1.5 }, /maxRetries/], @@ -74,7 +89,6 @@ describe('provider retry policy', () => { [{ mode: 'normal', retryableCodes: [''] }, /non-empty strings/], [{ mode: 'normal', retryableCodes: [429] }, /non-empty strings/], [{ mode: 'normal', maxRetires: 1 }, /unknown key "maxRetires"/], - [{ mode: 'always', maxRetries: 1 }, /unknown key "maxRetries"/], [{ mode: 'always', backoff: { initialDelay: 1 } }, /unknown key "initialDelay"/], [{ mode: 'sometimes' }, /mode must be "normal" or "always"/], ] as const)('rejects invalid policy %#', (config, message) => { diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index ed7d8d9bf8..45f523c129 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -217,7 +217,7 @@ describe('LlmRuntime', () => { expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured) expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, }) expect(() => ctx.llm.providerRetryPolicy('missing')).toThrow( expect.objectContaining({ code: 'NO_ADAPTER' }), diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 5a5dc2c181..6b69f7ba0c 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/README.md -README.md: a863ed3f5ef864b6eb6eb9a7a0c1ee2f40f247d6 -README.zh.md: 1c9bf8ba0814a74c5774d81a34a3266daa9c375b +README.md: 1b3ccb6afba226e0b6bcf9aacd0c9fa752fdfdbb +README.zh.md: a98874baf8d235758dbe370272ecf230cbfb112c diff --git a/packages/subagent/README.md b/packages/subagent/README.md index a863ed3f5e..1b3ccb6afb 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -18,6 +18,8 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes | +The Codex and Claude Code packages are independent optional Profile Bundles. Install either or both with `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; each package registers only its dormant Host provider. To grant a tool, copy a complete Agent Preset, remove `disabled` from each matching tool row, and start a new Session. Removing one package withdraws only that provider and its private runtime closure on the next Profile start. + See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). The subsystem reference — start requests, results, live runs, the provider contract, continuable background children — is [docs/subsystems/subagent.md](../../docs/subsystems/subagent.md); design rationale in the [subagent capability seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable background subagents](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [merged subagent control service](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md) Agent Notes. diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 1c9bf8ba08..a98874baf8 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -18,6 +18,8 @@ | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | +Codex 与 Claude Code 包是彼此独立的可选 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code` 安装其中一个或两个包,再重启该 Profile;每个包只注册自己的休眠 Host provider。要授予工具,请复制一份完整 Agent Preset,删除各对应工具行的 `disabled`,再启动新 Session。移除其中一个包后,下一次 Profile 启动只会撤回对应 provider 及其私有运行时闭包。 + 参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)的决策。 子系统参考——启动请求、结果、实时运行、提供方约定、可续跑后台子 agent——见 [docs/subsystems/subagent.md](../../docs/subsystems/subagent.md);设计依据见 [subagent 能力 seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可续跑后台 subagent](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)与[合并 subagent 控制服务](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md) Agent Note。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index a165540575..70d7ffab83 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085 -README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428 +README.md: 67a8cf199a2e85d975a4df90f56d5dbafb9f1e56 +README.zh.md: 0b8f2f285924de161cefd21c422fd40ac3597ad7 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 1a0d6e32b8..67a8cf199a 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers a Profile-named Claude Code subagent provider whose default name is `claude-code`. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, lets the pinned SDK select its installed platform CLI, submits one self-contained text task, and returns either the strict final answer or a separate safe failure diagnostic through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -14,9 +14,9 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id ## Native settings and interaction -The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. +The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. ## Capabilities and context @@ -26,40 +26,83 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| +| `providerName` | `claude-code` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | +| `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run from this Provider instance. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +| `permissionMode` value | Native behavior | +|---|---| +| `dontAsk` | Deny operations that are not already authorized instead of prompting. | +| `acceptEdits` | Accept file edits; any remaining permission prompt is denied by the unattended callback. | +| `auto` | Let Claude Code's native classifier allow or deny permission requests. | +| `plan` | Run in native planning mode, deny execution approval, and return the completed plan as the final answer. | +| `bypassPermissions` | Explicitly set the SDK's dangerous confirmation and bypass permission checks. | -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +Production omits `pathToClaudeCodeExecutable`, so Agent SDK 0.3.220 selects the matching native `claude` or `claude.exe` from its own platform package and passes that absolute command through the custom-spawn hook to `dsh-subprocess`. The provider does not inspect `PATH`, implement platform selection, or fall back to a host `claude`. Native settings and authentication remain authoritative, while `permissionMode` is the only query-level policy override. The plugin does not select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden; `PATH` does not choose the Claude executable. -The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services. +This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; installation brings the pinned Agent SDK and one compatible platform CLI payload into that Profile, while the declared `cordis.patch.yml` layer registers only the dormant `claude-code` Host provider and starts no Claude process. Removing the package withdraws that provider and its private runtime closure on the next Profile start. + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +dsh --profile +``` + +Installation controls Host availability, not model permission. The Bundle supplies the dormant default `claude-code` row; the Profile may replace that row's complete config or mount additional rows with distinct `providerName`, `permissionMode`, and `env` values. Loading an instance starts no Claude process until a bound tool calls it. Each `dsh-tool-subagent` row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. + +The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider and tool rows, and does not mount duplicate Job services. ```yaml -- id: subagent-claude-code +- id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: + providerName: claude-safe + permissionMode: dontAsk env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +- id: subagent-claude-bypass + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + providerName: claude-bypass + permissionMode: bypassPermissions + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +``` + +```yaml - id: jobs name: '@deepseek-ai/dsh-jobs-local' - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-claude-code +- id: tool-subagent-claude-safe + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-safe + toolName: subagent_claude_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-claude-bypass name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-code - toolName: subagent_claude_code + provider: claude-bypass + toolName: subagent_claude_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that both product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose eight platform packages carry Claude Code 2.1.220. A normal install selects one payload for the current OS, CPU, and Linux libc. For the current darwin-arm64 payload, `npm pack --dry-run --json` reports 74,858,812 packed bytes and 256,908,856 unpacked bytes; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test runs the SDK-selected CLI against a loopback Messages fixture and asserts that the shared subprocess argv begins with that platform package's native executable. Loader composition proves that installing the Bundle registers only the dormant Claude Code provider and starts no product process. + +Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload leaves provider registration dormant but makes the first delegation fail with the SDK's native-payload startup error. The provider neither probes a host CLI nor retries with one. + +Loader composition proves that the Bundle default, two additional named Claude instances, and the existing Codex package coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -69,7 +112,7 @@ The project owner's identity-scoped distribution authorization covers the offici #### What the model sees -The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation. +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from native Claude settings, the selected Provider instance's Profile configuration fixes the query's environment and non-interactive permission mode, and the executable version comes from the Bundle's pinned SDK platform payload. #### Token effect @@ -83,7 +126,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. #### Token effect @@ -96,10 +139,11 @@ Append-only: foreground adds one result after the reusable parent prefix, while ## Known Limitations and Deferred Work - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. -- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. -- **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. -- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. -- **Product payload is final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. +- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, or rewrite Claude settings; configuration and authentication failures surface as startup or run errors. +- **The SDK platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first query; there is no host-CLI fallback. +- **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. +- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 78dab14e5e..0b8f2f2859 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册由 Profile 命名、默认名称为 `claude-code` 的 Claude Code subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,让锁定版本的 SDK 选择随包安装的平台 CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或独立的安全失败诊断。 ## 启动与所有权 @@ -14,9 +14,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 原生设置与交互 -提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。 +提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 ## 能力与上下文 @@ -26,40 +26,83 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | 配置键 | 默认值 | 含义 | |---|---|---| +| `providerName` | `claude-code` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | +| `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +| `permissionMode` 值 | 原生行为 | +|---|---| +| `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | +| `acceptEdits` | 接受文件编辑;其余权限提示由无人值守回调拒绝。 | +| `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | +| `plan` | 使用原生规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 | +| `bypassPermissions` | 显式设置 SDK 的危险确认并跳过权限检查。 | -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +生产环境会省略 `pathToClaudeCodeExecutable`,因此 Agent SDK 0.3.220 会从自己的平台包中选择匹配的原生 `claude` 或 `claude.exe`,再通过 custom-spawn 钩子把该绝对命令交给 `dsh-subprocess`。提供方不会检查 `PATH`、重复实现平台选择,也不会回退到宿主 `claude`。原生设置与身份验证继续是权威来源,而 `permissionMode` 是唯一的 query 级策略覆盖。本插件不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承;`PATH` 不参与选择 Claude 可执行文件。 -下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。 +本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;安装会把锁定的 Agent SDK 与一个兼容的平台 CLI 载荷带入该 Profile,而包所声明的 `cordis.patch.yml` 层只注册休眠的 `claude-code` Host provider,不会启动 Claude 进程。移除该包后,下一次 Profile 启动会撤回这一 provider 及其私有运行时闭包。 + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +dsh --profile +``` + +安装决定 Host 可用性,而不是模型权限。Bundle 会提供休眠的默认 `claude-code` 配置项;Profile 可以替换该配置项的完整 config,也可以挂载更多具有不同 `providerName`、`permissionMode` 与 `env` 的配置项。加载实例本身不会在绑定工具调用前启动 Claude 进程。每个 `dsh-tool-subagent` 配置项指定一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 + +下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 配置项,新增产品提供方与工具配置项,而且不重复挂载 Job 服务。 ```yaml -- id: subagent-claude-code +- id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: + providerName: claude-safe + permissionMode: dontAsk env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +- id: subagent-claude-bypass + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + providerName: claude-bypass + permissionMode: bypassPermissions + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +``` + +```yaml - id: jobs name: '@deepseek-ai/dsh-jobs-local' - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-claude-code +- id: tool-subagent-claude-safe + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-safe + toolName: subagent_claude_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-claude-bypass name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-code - toolName: subagent_claude_code + provider: claude-bypass + toolName: subagent_claude_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个产品包能够共存且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其八个平台包都携带 Claude Code 2.1.220。普通安装会按当前操作系统、CPU 及 Linux libc 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json` 报告压缩包为 74,858,812 字节、解包后为 256,908,856 字节;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会让 SDK 选择 CLI,通过回环 Messages fixture 运行它,并断言共享子进程 argv 的首项就是该平台包的原生可执行文件。Loader 组合证明安装该 Bundle 只会注册休眠的 Claude Code provider,不会启动产品进程。 + +如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,提供方注册仍保持休眠,但第一次委派会以 SDK 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试。 + +Loader 组合证明 Bundle 默认实例、两个额外命名 Claude 实例与现有 Codex 包可以共存,而且不会启动任一产品。 限定于项目所有者身份的分发授权涵盖官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会认定其中声明的条款属于宽松许可;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -69,7 +112,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK #### 模型看到的内容 -Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。 +Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Claude 设置,所选提供方实例的 Profile 配置会固定该 query 的环境与非交互权限模式,而可执行版本来自 Bundle 锁定的 SDK 平台载荷。 #### 对 token 的影响 @@ -83,7 +126,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -96,10 +139,11 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 ## 已知限制与后续工作 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 -- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 -- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 -- **产品载荷仅包含最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。 +- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录或改写 Claude 设置;配置与身份验证失败会呈现为启动错误或运行错误。 +- **委派时必须存在 SDK 平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次 query 时失败;不会回退到宿主 CLI。 +- **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 +- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/cordis.patch.yml b/packages/subagent/subagent-claude-code/cordis.patch.yml new file mode 100644 index 0000000000..63c0319626 --- /dev/null +++ b/packages/subagent/subagent-claude-code/cordis.patch.yml @@ -0,0 +1,6 @@ +# This optional Profile layer registers the dormant Claude Code provider. Agent +# presets separately decide whether one session receives its delegation tool. + +- insert: + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 0c0e54cf11..fe974b0344 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -28,9 +28,15 @@ "files": [ "lib/index.js", "lib/invariant.js", + "cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -43,7 +49,9 @@ "dependencies": { "@anthropic-ai/sdk": "0.93.0", "@anthropic-ai/claude-agent-sdk": "0.3.220", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index ccd150b746..51b4bdc4a2 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -1,7 +1,7 @@ /** - * Fixed Claude Code one-shot subagent provider. Every accepted run invokes - * the official Agent SDK in the delegating Session's workspace and places - * the SDK-spawned real CLI under the shared subprocess owner. + * Profile-named Claude Code one-shot subagent provider. Every accepted run + * invokes the official Agent SDK in the delegating Session's workspace and + * places the SDK-spawned real CLI under the shared subprocess owner. * * @module @deepseek-ai/dsh-subagent-claude-code */ @@ -18,29 +18,46 @@ import { type SubagentProvider, } from '@deepseek-ai/dsh-subagent' import { + CLAUDE_CODE_PERMISSION_MODES, + DEFAULT_CLAUDE_CODE_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, startClaudeCodeRun, + type ClaudeCodePermissionMode, type ClaudeCodeRunSpec, } from './run.ts' export const name = 'subagent-claude-code' export const inject = ['subagents', 'subprocess'] -/* jscpd:ignore-start -- sibling product providers intentionally expose the - * same two deployment-owned fields without adding a shared config owner. */ -/** Deployment-owned environment and process-release bound. */ +const DEFAULT_PROVIDER_NAME = 'claude-code' + +/* jscpd:ignore-start -- sibling product providers intentionally expose + * overlapping deployment-owned fields without adding a shared config owner. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `claude-code`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** + * Native non-interactive mode fixed for this Provider instance. Defaults to + * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier, + * `plan` returns a plan without approving execution, and + * `bypassPermissions` explicitly skips permission checks. + */ + permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } export const Config: z = z.object({ + providerName: z.string().min(1).default(DEFAULT_PROVIDER_NAME), env: z.dict(z.string()).default({}), + permissionMode: z.union([...CLAUDE_CODE_PERMISSION_MODES]) + .default(DEFAULT_CLAUDE_CODE_PERMISSION_MODE), disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) @@ -50,11 +67,11 @@ type ResolvedConfig = Required /* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror * the Codex sibling; each product's lifecycle remains package-private. */ class ClaudeCodeProvider implements SubagentProvider { - readonly name = 'claude-code' readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES readonly inheritsParentContext = false constructor( + readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig, ) {} @@ -66,24 +83,19 @@ class ClaudeCodeProvider implements SubagentProvider { 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } - const executable = await this.ctx.subprocess.resolveExecutable( - 'claude', - this.config.env, - request.signal, - ) const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, ), - executable, + permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), onError: (error, stopReason) => { this.ctx.logger.warn( - `subagent-claude-code: child run failed (${stopReason}): ${error.message}`, + `subagent-claude-code "${this.name}": child run failed (${stopReason}): ${error.message}`, ) }, } @@ -92,12 +104,17 @@ class ClaudeCodeProvider implements SubagentProvider { } /** - * Register the fixed `claude-code` provider. + * Register one Profile-named Claude Code provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - explicit child environment and disposal grace. + * @param config - registry name, permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const resolved: ResolvedConfig = { + providerName: config.providerName ?? DEFAULT_PROVIDER_NAME, + env: config.env as Record, + permissionMode: config.permissionMode ?? DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + disposeGraceMs: config.disposeGraceMs as number, + } assertPositiveFinite( 'subagent-claude-code', 'disposeGraceMs', @@ -108,6 +125,10 @@ export function apply(ctx: Context, config: Config): void { `subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, ) } - ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved)) + ctx.subagents.registerProvider(new ClaudeCodeProvider( + resolved.providerName, + ctx, + resolved, + )) } /* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 1e2a259ca2..32a545bf08 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -6,7 +6,6 @@ */ import { EventEmitter } from 'node:events' -import { extname } from 'node:path' import type { SpawnedProcess, SpawnOptions, @@ -17,8 +16,6 @@ import { type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' -const WINDOWS_BATCH_EXECUTABLE_ENV = 'DSH_CLAUDE_CODE_EXECUTABLE' - function thrown(value: unknown): Error { /* v8 ignore next -- the subprocess seam rejects with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -43,33 +40,22 @@ export function sdkEnvironmentOverlay( * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. * @param graceMs - process-tree termination grace. - * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. - * @remarks The batch-shim path quotes only the resolved executable. The pinned SDK - * supplies fixed flag arguments without cmd metacharacters; cmd reparses that tail. */ export function claudeSpawnSpec( options: SpawnOptions, graceMs: number, - platform: NodeJS.Platform = process.platform, ): SubprocessSpawnSpec { if (options.cwd === undefined || options.cwd.length === 0) { throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } - const extension = extname(options.command).toLowerCase() - const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') - const env = sdkEnvironmentOverlay(options.env) - const argv = batchShim - ? ['cmd.exe', '/d', '/v:off', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] - : [options.command, ...options.args] - if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { - argv, + argv: [options.command, ...options.args], cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env, + env: sdkEnvironmentOverlay(options.env), } } diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6c1e0a8dbf..fef044450c 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -38,14 +38,42 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Claude Code permission modes that cannot wait for a human response. */ +export const CLAUDE_CODE_PERMISSION_MODES = [ + 'dontAsk', + 'acceptEdits', + 'auto', + 'plan', + 'bypassPermissions', +] as const satisfies readonly NonNullable[] + +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] + +/** Safe default for unattended Claude Code runs. */ +export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk' + +const SUPPORTED_UNATTENDED_DIALOG_KINDS = [ + 'refusal_fallback_prompt', +] satisfies NonNullable + +function unattendedDiagnostic( + mode: ClaudeCodePermissionMode, + request: 'tool permission' | 'MCP elicitation' | 'user dialog', + decision: 'denied' | 'declined' | 'cancelled', + reason: string, +): string { + return `Claude Code unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}` +} + /* jscpd:ignore-start -- sibling providers intentionally keep product-private * run inputs and error normalization instead of adding a shared lifecycle owner. */ /** Fully resolved inputs for one official Claude Agent SDK query. */ export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string - /** Exact native Claude Code executable resolved from the host PATH. */ - readonly executable: string + /** Profile-selected native non-interactive permission mode. */ + readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -60,6 +88,11 @@ function thrown(value: unknown): Error { /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) } + +/** Read live request cancellation across awaited startup cleanup. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} /* jscpd:ignore-end */ /** @@ -107,13 +140,19 @@ export function successfulResult(message: SDKResultMessage): string { * Consume the complete SDK stream and require one strict success plus normal * iterator completion. * @param query - published official SDK query. + * @param onPermissionDenied - records a safe fact when the SDK reports native denial. * @returns the completed shared result. */ export async function consumeClaudeQuery( query: AsyncIterable, + onPermissionDenied?: () => void, ): Promise { let answer: string | undefined for await (const message of query) { + if (message.type === 'system' && message.subtype === 'permission_denied') { + onPermissionDenied?.() + continue + } if (message.type !== 'result') continue answer = successfulResult(message) } @@ -172,20 +211,59 @@ export async function disposeClaudeCodeChild( * @param spec - Workspace, environment, process service, and disposal policy. * @param controller - per-run cancellation owner. * @param capture - receives the real managed child synchronously from the SDK hook. + * @param captureDiagnostic - receives safe facts from unattended interaction callbacks. * @returns options that inherit native settings while disabling persistence and user questions. */ export function claudeQueryOptions( spec: ClaudeCodeRunSpec, controller: AbortController, capture: (child: SubprocessHandle) => void, + captureDiagnostic: (diagnostic: string) => void, ): Options { return { abortController: controller, cwd: spec.cwd, - pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, - disallowedTools: ['AskUserQuestion'], + disallowedTools: spec.permissionMode === 'plan' + ? ['AskUserQuestion', 'ExitPlanMode'] + : ['AskUserQuestion'], + permissionMode: spec.permissionMode, + ...spec.permissionMode === 'bypassPermissions' + ? { allowDangerouslySkipPermissions: true } + : { + canUseTool: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'the provider does not request human approval', + )) + return Promise.resolve({ + behavior: 'deny' as const, + message: 'This unattended Claude Code subagent cannot request human approval.', + }) + }, + }, + onElicitation: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'MCP elicitation', + 'declined', + 'the provider does not collect interactive MCP input', + )) + return Promise.resolve({ action: 'decline' }) + }, + onUserDialog: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'user dialog', + 'cancelled', + 'the provider does not render blocking dialogs', + )) + return Promise.resolve({ behavior: 'cancelled' as const }) + }, + supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS, spawnClaudeCodeProcess: (options: SpawnOptions) => { const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) capture(child) @@ -220,12 +298,21 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined + let diagnostic: string | undefined + const captureDiagnostic = (value: string): void => { + diagnostic = value + } try { query = officialQuery({ prompt, - options: claudeQueryOptions(spec, controller, (captured) => { - child = captured - }), + options: claudeQueryOptions( + spec, + controller, + (captured) => { + child = captured + }, + captureDiagnostic, + ), }) if (child === undefined || child.pid <= 0) { throw new Error( @@ -239,12 +326,49 @@ export async function startClaudeCodeRun( request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted requestCancel() + const startupError = thrown(error) + if (child !== undefined && child.pid <= 0) { + let closeError: Error | undefined + try { + query?.close() + } catch (disposeError: unknown) { + closeError = thrown(disposeError) + } + + let spawnError = startupError + try { + await child.done + } catch (childError: unknown) { + spawnError = thrown(childError) + } + + const cancelled = cancelledBeforeCleanup || isAborted(request.signal) + if (closeError !== undefined) { + const failures = cancelled + ? [ + new Error('subagent-claude-code: request was aborted before SDK startup'), + spawnError, + closeError, + ] + : [spawnError, closeError] + throw new AggregateError( + failures, + cancelled + ? `subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: ${spawnError.message}; query cleanup also failed` + : `subagent-claude-code: Claude Code process startup failed: ${spawnError.message}; query cleanup also failed`, + ) + } + if (cancelled) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + throw spawnError + } if (child !== undefined) { try { await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [startupError, thrown(disposeError)], 'subagent-claude-code: startup failed and CLI cleanup also failed', ) } @@ -253,23 +377,30 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [startupError, thrown(disposeError)], 'subagent-claude-code: startup failed and query cleanup also failed', ) } } - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. - if (cancelledBeforeCleanup || request.signal.aborted) { + if (cancelledBeforeCleanup || isAborted(request.signal)) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrown(error) + throw startupError } const publishedQuery = query const publishedChild = child const result = settleRunResult({ - attempt: () => consumeClaudeQuery(publishedQuery), + attempt: () => consumeClaudeQuery(publishedQuery, () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'Claude Code denied the request before an interactive prompt', + )) + }), collectOutput: () => [], + collectDiagnostic: () => diagnostic, cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, 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 37d02657cb..5f339ce22e 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -12,26 +13,34 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'cordis.yml') +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + dsh?: { bundle?: { patch?: string } } +} +const bundlePatch = manifest.dsh?.bundle?.patch +if (bundlePatch === undefined) throw new Error('Claude Code package must declare a Bundle patch') +const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('product-provider public Loader composition', () => { - it('loads both opt-in packages, one-shot task tools, and job controls without starting either product', async () => { + it('loads the Bundle default, two named Claude instances, their tools, and Codex without starting either product', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'product-provider Loader composition', tempDirPrefix: 'dsh-product-provider-loader-', binScript: driver, libBinScript: driver, configPath, + binArgs: [configPath, bundlePatchPath], tsconfigPath: repoTsconfig, env: { - // Loading either optional package must not probe or start its binary. + // Loading the optional package must not probe or start a Claude binary. PATH: '', }, }) expect(stderr).toBe('') expect(JSON.parse(stdout)).toEqual({ - registeredProviders: ['codex', 'claude-code'], + registeredProviders: ['codex', 'claude-primary', 'claude-secondary', 'claude-code'], providers: [ { name: 'codex', @@ -53,6 +62,26 @@ describe('product-provider public Loader composition', () => { }, inheritsParentContext: false, }, + { + name: 'claude-primary', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + { + name: 'claude-secondary', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, ], tools: [ { @@ -65,6 +94,16 @@ describe('product-provider public Loader composition', () => { parameterNames: ['description', 'prompt', 'run_in_background'], required: ['description', 'prompt'], }, + { + name: 'subagent_claude_primary', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_claude_secondary', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, ], jobTools: ['job_kill', 'job_list', 'job_output'], starts: 0, diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index d8a04cf953..78d2f84176 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -4,6 +4,12 @@ import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'nod export type MessagesBehavior = | { readonly kind: 'complete'; readonly text: string } | { readonly kind: 'hold' } + | { + readonly kind: 'tool-use' + readonly toolName: string + readonly input: Record + readonly finalText?: string + } /** One recorded Anthropic Messages request. */ interface RecordedMessagesRequest { @@ -81,6 +87,67 @@ function complete( response.end() } +function toolUse( + response: ServerResponse, + body: Record, + toolName: string, + input: Record, +): void { + const model = typeof body.model === 'string' ? body.model : 'fixture-model' + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + event(response, 'message_start', { + type: 'message_start', + message: { + id: 'msg_dsh_fixture_tool_use', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 7, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + event(response, 'content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_dsh_fixture', + name: toolName, + input: {}, + }, + }) + event(response, 'content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(input), + }, + }) + event(response, 'content_block_stop', { + type: 'content_block_stop', + index: 0, + }) + event(response, 'message_delta', { + type: 'message_delta', + delta: { stop_reason: 'tool_use', stop_sequence: null }, + usage: { output_tokens: 1 }, + }) + event(response, 'message_stop', { type: 'message_stop' }) + response.end() +} + /** * Start a loopback-only Anthropic Messages SSE fixture. * @param behavior - the single response behavior for this fixture. @@ -118,8 +185,15 @@ export async function startMessagesFixture( requestStartedResolve() if (behavior.kind === 'complete') { complete(response, body, behavior.text) + } else if (behavior.kind === 'tool-use' && requests.length === 1) { + toolUse(response, body, behavior.toolName, behavior.input) + } else if ( + behavior.kind === 'tool-use' + && behavior.finalText !== undefined + ) { + complete(response, body, behavior.finalText) } - // A hold deliberately leaves the response pending until client abort. + // A hold, or a tool-use without final text, waits for client abort. }) }) await new Promise((resolve, reject) => { diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 89d08a1878..1881e14d33 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -7,7 +7,7 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' @@ -87,7 +87,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( ]) mkdirSync(directory) const env = { - PATH: `${dirname(claudeBin)}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index f6767817c8..a8e38885ef 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,14 +1,15 @@ import { execFile } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readFileSync, - symlinkSync, + realpathSync, writeFileSync, } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import type { @@ -23,6 +24,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' +import type { ClaudeCodePermissionMode } from '../src/run.ts' import { startMessagesFixture, type MessagesBehavior, @@ -119,37 +121,39 @@ interface RealHarness { readonly parent: Agent readonly workspace: string readonly env: Record - readonly executable: string } -async function realHarness(behavior: MessagesBehavior): Promise<{ - readonly harness: RealHarness +interface RealInstanceFixture { readonly fixture: MessagesFixture -}> { + readonly workspace: string + readonly env: Record +} + +async function realInstanceFixture( + behavior: MessagesBehavior, + nativeAllow: readonly string[] = [], +): Promise { const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-')) roots.push(root) const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native&%literal%!bang!bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) - mkdirSync(nativeBin) - const executable = join(nativeBin, process.platform === 'win32' ? 'claude.cmd' : 'claude') - if (process.platform === 'win32') { - writeFileSync(executable, `@echo off\r\n"${claudeBin}" %*\r\n`) - } else { - symlinkSync(claudeBin, executable) - } writeFileSync( join(claudeConfig, 'settings.json'), - `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, + `${JSON.stringify({ + model: settingsModel, + permissions: { + defaultMode: 'default', + ...nativeAllow.length === 0 ? {} : { allow: nativeAllow }, + }, + }, null, 2)}\n`, ) const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) const env = { - PATH: `${nativeBin}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_API_KEY: fakeKey, ANTHROPIC_BASE_URL: fixture.baseUrl, CLAUDE_CONFIG_DIR: claudeConfig, @@ -164,6 +168,16 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ ALL_PROXY: '', NO_PROXY: '127.0.0.1,localhost', } + return { fixture, workspace, env } +} + +interface RealRuntime { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] +} + +async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SubagentRuntime) @@ -177,14 +191,38 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ handles.push(handle) return handle }) - await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 }) + return { ctx, handles, spawnSpecs } +} + +async function realHarness( + behavior: MessagesBehavior, + permissionMode?: ClaudeCodePermissionMode, + nativeAllow: readonly string[] = [], +): Promise<{ + readonly harness: RealHarness + readonly fixture: MessagesFixture +}> { + const instance = await realInstanceFixture(behavior, nativeAllow) + const { ctx, handles, spawnSpecs } = await realRuntime() + await ctx.plugin(claudeCode, { + env: instance.env, + ...permissionMode === undefined ? {} : { permissionMode }, + disposeGraceMs: 3_000, + }) const parent = { id: 'real-parent', - session: { header: { cwd: workspace } }, + session: { header: { cwd: instance.workspace } }, } as unknown as Agent return { - harness: { ctx, handles, spawnSpecs, parent, workspace, env, executable }, - fixture, + harness: { + ctx, + handles, + spawnSpecs, + parent, + workspace: instance.workspace, + env: instance.env, + }, + fixture: instance.fixture, } } @@ -225,7 +263,7 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') - const version = await execFileAsync(process.platform === 'win32' ? claudeBin : harness.executable, ['--version'], { + const version = await execFileAsync(claudeBin, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -242,18 +280,16 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') - if (process.platform === 'win32') { - expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ - 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', - ]) - const batchExecutable = harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE - expect(batchExecutable?.startsWith('"')).toBe(true) - expect(batchExecutable?.endsWith('"')).toBe(true) - expect(batchExecutable?.slice(1, -1).toLowerCase()) - .toBe(harness.executable.toLowerCase()) - } else { - expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) - } + const spawnedExecutable = harness.spawnSpecs[0]?.argv[0] + expect(spawnedExecutable).toBeDefined() + expect(process.platform === 'win32' + ? realpathSync(spawnedExecutable!).toLowerCase() + : realpathSync(spawnedExecutable!)) + .toBe(process.platform === 'win32' + ? realpathSync(claudeBin).toLowerCase() + : realpathSync(claudeBin)) + expect(harness.spawnSpecs[0]?.env) + .not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! @@ -278,6 +314,80 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) + it('runs two named instances concurrently and unloads one without revoking its run', async () => { + const safeInstance = await realInstanceFixture({ kind: 'hold' }) + const bypassInstance = await realInstanceFixture({ + kind: 'complete', + text: 'NAMED_BYPASS_RESULT', + }) + const { ctx, handles, spawnSpecs } = await realRuntime() + const safeFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-safe', + env: safeInstance.env, + permissionMode: 'dontAsk', + disposeGraceMs: 3_000, + }) + const bypassFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-bypass', + env: bypassInstance.env, + permissionMode: 'bypassPermissions', + disposeGraceMs: 3_000, + }) + const safeParent = { + id: 'safe-parent', + session: { header: { cwd: safeInstance.workspace } }, + } as unknown as Agent + const bypassParent = { + id: 'bypass-parent', + session: { header: { cwd: bypassInstance.workspace } }, + } as unknown as Agent + const safeController = new AbortController() + + const [safeRun, bypassRun] = await Promise.all([ + ctx.subagents.start('claude-safe', { + prompt: [{ type: 'text', text: 'Hold the safe instance.' }], + parent: safeParent, + signal: safeController.signal, + }), + ctx.subagents.start('claude-bypass', { + prompt: [{ type: 'text', text: 'Complete the bypass instance.' }], + parent: bypassParent, + signal: new AbortController().signal, + }), + ]) + await safeInstance.fixture.requestStarted + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['claude-bypass']) + await expect(ctx.subagents.start('claude-safe', { + prompt: [{ type: 'text', text: 'This start must fail.' }], + parent: safeParent, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'NAMED_BYPASS_RESULT' }], + stopReason: 'completed', + }) + safeController.abort(new Error('cancel only the published safe run')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect(safeInstance.fixture.requests).toHaveLength(1) + expect(bypassInstance.fixture.requests).toHaveLength(1) + expect(safeInstance.fixture.requests[0]?.body.messages) + .not.toEqual(bypassInstance.fixture.requests[0]?.body.messages) + expect(spawnSpecs.map(spec => spec.env?.CLAUDE_CONFIG_DIR).sort()) + .toEqual([ + safeInstance.env.CLAUDE_CONFIG_DIR, + bypassInstance.env.CLAUDE_CONFIG_DIR, + ].sort()) + await expectQuiescent(handles) + await bypassFiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + it('maps a real CLI process failure to error', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const run = await startRequest(harness, 'Exercise the failure path.') @@ -294,6 +404,80 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) + it('overrides interactive settings, denies a write, and returns a safe diagnostic', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-denied-target-')) + roots.push(root) + const target = join(root, 'denied.txt') + const { harness } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'SECRET_TOKEN must not reach the diagnostic', + }, + }) + const run = await startRequest(harness, 'Write the requested fixture file.') + await vi.waitFor(() => { + expect(observedSdkMessages.some(message => + message.type === 'system' + && message.subtype === 'permission_denied')).toBe(true) + }, { timeout: 30_000 }) + expect(existsSync(target)).toBe(false) + harness.handles[0]!.terminate() + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain(target) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + await run.dispose() + await expectQuiescent(harness.handles) + }) + + it('runs an explicitly selected bypass write in the isolated workspace', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-bypass-target-')) + roots.push(root) + const target = join(root, 'bypass.txt') + const { harness } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'bypass write completed', + }, + finalText: 'write complete', + }, 'bypassPermissions') + const run = await startRequest(harness, 'Write the requested fixture file.') + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'write complete' }], + stopReason: 'completed', + }) + expect(readFileSync(target, 'utf8')).toBe('bypass write completed') + await run.dispose() + await expectQuiescent(harness.handles) + }) + + it('returns the completed plan without approving execution', async () => { + const { harness, fixture } = await realHarness({ + kind: 'tool-use', + toolName: 'ExitPlanMode', + input: {}, + finalText: 'PLAN_ONLY_RESULT', + }, 'plan', ['ExitPlanMode']) + const run = await startRequest(harness, 'Design the fixture change without implementing it.') + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }], + stopReason: 'completed', + }) + expect(fixture.requests).toHaveLength(2) + expect(JSON.stringify(fixture.requests[1]?.body.messages)) + .toContain('ExitPlanMode exists but is not enabled in this context') + await run.dispose() + await expectQuiescent(harness.handles) + }) + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const controller = new AbortController() 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 a3df59a74f..8f1ad34d8a 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 @@ -1,13 +1,18 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' import type { Options, Query, SDKMessage, + SDKPermissionDeniedMessage, SDKResultMessage, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as yaml from 'js-yaml' import { afterEach, beforeEach, @@ -36,6 +41,8 @@ import { sdkEnvironmentOverlay, } from '../src/process.ts' import { + CLAUDE_CODE_PERMISSION_MODES, + DEFAULT_CLAUDE_CODE_PERMISSION_MODE, claudeQueryOptions, consumeClaudeQuery, disposeClaudeCodeChild, @@ -52,6 +59,19 @@ type QueryFactory = (params: { const queryMock = vi.hoisted(() => vi.fn()) +const CLAUDE_AGENT_SDK_VERSION = '0.3.220' +const CLAUDE_CODE_VERSION = '2.1.220' +const CLAUDE_PLATFORM_PACKAGES = [ + '@anthropic-ai/claude-agent-sdk-darwin-arm64', + '@anthropic-ai/claude-agent-sdk-darwin-x64', + '@anthropic-ai/claude-agent-sdk-linux-arm64', + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl', + '@anthropic-ai/claude-agent-sdk-linux-x64', + '@anthropic-ai/claude-agent-sdk-linux-x64-musl', + '@anthropic-ai/claude-agent-sdk-win32-arm64', + '@anthropic-ai/claude-agent-sdk-win32-x64', +] as const + vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({ ...await importOriginal(), query: queryMock, @@ -189,6 +209,20 @@ function failure( } as SDKResultMessage } +function permissionDenied(): SDKPermissionDeniedMessage { + return { + type: 'system', + subtype: 'permission_denied', + tool_name: 'Bash', + tool_use_id: 'tool-secret', + decision_reason_type: 'mode', + decision_reason: 'contains /private/secret.txt', + message: 'command with SECRET_TOKEN was denied', + uuid: '00000000-0000-4000-8000-000000000001', + session_id: 'session-secret', + } +} + function queryFrom( messages: readonly SDKMessage[], after?: Error, @@ -248,7 +282,7 @@ function fakeRun( const options: FakeRun['options'] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -282,6 +316,66 @@ afterEach(() => { }) describe('task admission and package contracts', () => { + it('ships one independently installable provider-only Bundle patch', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + files?: string[] + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.files).toContain('cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty( + '@anthropic-ai/claude-agent-sdk', + CLAUDE_AGENT_SDK_VERSION, + ) + expect(manifest.dependencies).toHaveProperty( + '@modelcontextprotocol/sdk', + '^1.29.0', + ) + expect(manifest.dependencies).toHaveProperty('zod', '^4.4.3') + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') + + const sdkRoot = dirname(fileURLToPath( + import.meta.resolve('@anthropic-ai/claude-agent-sdk'), + )) + const sdkManifest = JSON.parse(readFileSync( + resolve(sdkRoot, 'package.json'), + 'utf8', + )) as { + version: string + claudeCodeVersion: string + optionalDependencies: Record + } + expect(sdkManifest.version).toBe(CLAUDE_AGENT_SDK_VERSION) + expect(sdkManifest.claudeCodeVersion).toBe(CLAUDE_CODE_VERSION) + expect(sdkManifest.optionalDependencies).toEqual(Object.fromEntries( + CLAUDE_PLATFORM_PACKAGES.map(packageName => [ + packageName, + CLAUDE_AGENT_SDK_VERSION, + ]), + )) + const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8') + for (const packageName of CLAUDE_PLATFORM_PACKAGES) { + expect(lockfile).toContain( + ` '${packageName}@${CLAUDE_AGENT_SDK_VERSION}':`, + ) + expect(lockfile).toContain( + ` '${packageName}': ${CLAUDE_AGENT_SDK_VERSION}`, + ) + } + + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) + const rows = Array.isArray(parsed) + ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) + : [] + expect(rows).toEqual([{ + id: 'subagent-claude-code', + name: '@deepseek-ai/dsh-subagent-claude-code', + }]) + expect(JSON.stringify(rows)).not.toContain('tool-subagent') + }) + it('preserves text sequences and rejects empty, blank, and non-text tasks', () => { expect(textTask([ { type: 'text', text: 'one' }, @@ -294,7 +388,7 @@ describe('task admission and package contracts', () => { .toThrow('must not be empty') }) - it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) @@ -325,6 +419,146 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('keeps named instances, runs, and HMR ownership isolated', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const safeChild = fakeChild() + const bypassChild = fakeChild() + const spawnSpecs: SubprocessSpawnSpec[] = [] + vi.spyOn(ctx.subprocess, 'resolveExecutable') + .mockResolvedValue('/native/claude') + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) + return spec.env?.DSH_CLAUDE_INSTANCE === 'safe' + ? safeChild.handle + : bypassChild.handle + }) + const queryOptions: Options[] = [] + queryMock.mockImplementation(({ options }) => { + queryOptions.push(options) + options.spawnClaudeCodeProcess!(sdkSpawnOptions({ + command: options.pathToClaudeCodeExecutable!, + cwd: options.cwd!, + env: options.env!, + signal: options.abortController!.signal, + })) + return options.permissionMode === 'dontAsk' + ? waitingQuery(options.abortController!.signal) + : queryFrom([success('bypass answer')]) + }) + + const added: string[] = [] + const started: string[] = [] + const ended: string[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', provider => void added.push(provider.name)) + ctx.on('subagent/start', info => void started.push(info.provider)) + ctx.on('subagent/end', info => void ended.push(info.provider)) + ctx.on('subagent/provider-removed', providerName => void removed.push(providerName)) + const safeFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-safe', + env: { DSH_CLAUDE_INSTANCE: 'safe' }, + permissionMode: 'dontAsk', + disposeGraceMs: 11, + }) + const bypassFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-bypass', + env: { DSH_CLAUDE_INSTANCE: 'bypass' }, + permissionMode: 'bypassPermissions', + disposeGraceMs: 29, + }) + expect(ctx.subagents.list()).toEqual(['claude-safe', 'claude-bypass']) + expect(added).toEqual(['claude-safe', 'claude-bypass']) + + const safeController = new AbortController() + const [safeRun, bypassRun] = await Promise.all([ + ctx.subagents.start('claude-safe', request(undefined, safeController.signal)), + ctx.subagents.start('claude-bypass', request()), + ]) + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['claude-bypass']) + expect(removed).toEqual(['claude-safe']) + await expect(ctx.subagents.start('claude-safe', request())) + .rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'bypass answer' }], + stopReason: 'completed', + }) + safeController.abort(new Error('stop only the safe instance')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(queryOptions.map(options => ({ + instance: options.env?.DSH_CLAUDE_INSTANCE, + permissionMode: options.permissionMode, + }))).toEqual([ + { instance: 'safe', permissionMode: 'dontAsk' }, + { instance: 'bypass', permissionMode: 'bypassPermissions' }, + ]) + expect(spawnSpecs.map(spec => ({ + instance: spec.env?.DSH_CLAUDE_INSTANCE, + graceMs: spec.graceMs, + }))).toEqual([ + { instance: 'safe', graceMs: 11 }, + { instance: 'bypass', graceMs: 29 }, + ]) + + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect([...started].sort()).toEqual(['claude-bypass', 'claude-safe']) + expect([...ended].sort()).toEqual(['claude-bypass', 'claude-safe']) + expect(safeChild.terminate).toHaveBeenCalledOnce() + expect(bypassChild.terminate).toHaveBeenCalledOnce() + await bypassFiber.dispose() + expect(removed).toEqual(['claude-safe', 'claude-bypass']) + await ctx.fiber.dispose() + }) + + it('rejects duplicate provider names without replacing the first instance', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const firstFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-duplicate', + }) + const first = ctx.subagents.getProvider('claude-duplicate') + await expect(ctx.plugin(claudeCode, { + providerName: 'claude-duplicate', + permissionMode: 'bypassPermissions', + })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' }) + expect(ctx.subagents.getProvider('claude-duplicate')).toBe(first) + expect(ctx.subagents.list()).toEqual(['claude-duplicate']) + await firstFiber.dispose() + await ctx.fiber.dispose() + }) + + it('accepts only the five fixed non-interactive permission modes', () => { + expect(claudeCode.Config({}).providerName).toBe('claude-code') + expect(claudeCode.Config({ providerName: 'claude-safe' }).providerName) + .toBe('claude-safe') + expect(() => claudeCode.Config({ providerName: '' })).toThrow() + expect(claudeCode.Config({}).permissionMode) + .toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE) + for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) { + expect(claudeCode.Config({ permissionMode }).permissionMode) + .toBe(permissionMode) + } + for (const permissionMode of ['default', 'interactive', 'future-mode']) { + expect(() => claudeCode.Config({ permissionMode } as never)).toThrow() + } + }) + + it('resolves the safe permission default when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) + expect(ctx.subagents.getProvider('claude-code')).toBeDefined() + await ctx.fiber.dispose() + }) + it('starts through the registered provider with its resolved config and diagnostics', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -333,18 +567,20 @@ describe('task admission and package contracts', () => { const spawn = vi.spyOn(ctx.subprocess, 'spawn') .mockImplementation(() => child.handle) const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') - .mockResolvedValue('/native/claude') + .mockResolvedValue('/host/bin/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { + providerName: 'claude-diagnostic', env: { ANTHROPIC_API_KEY: 'provider-fake-key', CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config', HOME: '/private/tmp/dsh-claude-code-unit-home', }, + permissionMode: 'auto', disposeGraceMs: 29, }) - await expect(ctx.subagents.start('claude-code', { + await expect(ctx.subagents.start('claude-diagnostic', { ...request(), parent: { id: 'parent-without-cwd', @@ -355,12 +591,17 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() - resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) - await expect(ctx.subagents.start('claude-code', request())) - .rejects.toThrow('claude missing from PATH') - expect(queryMock).not.toHaveBeenCalled() + vi.stubEnv('PATH', '/host/bin') + queryMock.mockImplementationOnce(() => { + throw new Error( + 'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.', + ) + }) + await expect(ctx.subagents.start('claude-diagnostic', request())) + .rejects.toThrow('Native CLI binary for fixture-platform not found') + expect(resolveExecutable).not.toHaveBeenCalled() - const run = await ctx.subagents.start('claude-code', request()) + const run = await ctx.subagents.start('claude-diagnostic', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() await expect(run.result).resolves.toEqual({ @@ -368,15 +609,12 @@ describe('task admission and package contracts', () => { stopReason: 'error', }) expect(warn).toHaveBeenCalledWith(expect.stringContaining( - 'subagent-claude-code: child run failed (error):', + 'subagent-claude-code "claude-diagnostic": child run failed (error):', )) - expect(resolveExecutable).toHaveBeenCalledWith( - 'claude', - expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), - expect.any(AbortSignal), - ) - expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) - .toBe('/native/claude') + expect(resolveExecutable).not.toHaveBeenCalled() + expect(queryMock.mock.calls[1]?.[0].options) + .not.toHaveProperty('pathToClaudeCodeExecutable') + expect(queryMock.mock.calls[1]?.[0].options.permissionMode).toBe('auto') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -456,20 +694,17 @@ describe('official spawn projection', () => { )).toThrow('SDK spawn request omitted its workspace') }) - it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => { - const command = String.raw`C:\Program Files\Claude\claude.${extension}` + it('forwards the SDK-selected Windows native executable without a batch shim', () => { + const command = String.raw`C:\Program Files\Claude\claude.exe` const spec = claudeSpawnSpec(sdkSpawnOptions({ command, args: ['--output-format', 'stream-json'], - }), 7, 'win32') + }), 7) expect(spec.argv).toEqual([ - 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', - '--output-format', 'stream-json', + command, '--output-format', 'stream-json', ]) - expect(spec.env).toEqual(expect.objectContaining({ - DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`, - })) + expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') }) it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { @@ -530,16 +765,17 @@ describe('official spawn projection', () => { }) describe('query options and result mapping', () => { - it('builds the fixed unattended options over the scrubbed environment', () => { + it('builds the fixed unattended options over the scrubbed environment', async () => { vi.stubEnv('HOST_VISIBLE', 'visible') vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak') vi.stubEnv('DSH_INTERNAL', 'must-not-leak') const child = fakeChild() const spawn = vi.fn(() => child.handle) const captured: SubprocessHandle[] = [] + const diagnostics: string[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', + permissionMode: 'acceptEdits', env: { HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -548,32 +784,70 @@ describe('query options and result mapping', () => { spawn, } const controller = new AbortController() - const options = claudeQueryOptions(spec, controller, (value) => { - captured.push(value) - }) + const options = claudeQueryOptions( + spec, + controller, + (value) => { + captured.push(value) + }, + value => diagnostics.push(value), + ) expect(options).toMatchObject({ abortController: controller, cwd: '/workspace', - pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], + permissionMode: 'acceptEdits', + supportedDialogKinds: ['refusal_fallback_prompt'], }) + expect(options).not.toHaveProperty('pathToClaudeCodeExecutable') + expect(options).not.toHaveProperty('allowDangerouslySkipPermissions') expect(options.env).toMatchObject({ HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', }) expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN') expect(options.env).not.toHaveProperty('DSH_INTERNAL') - for (const omitted of [ - 'settingSources', - 'canUseTool', - 'onElicitation', - 'onUserDialog', - 'supportedDialogKinds', - ]) { - expect(options).not.toHaveProperty(omitted) - } + expect(options).not.toHaveProperty('settingSources') + + const callbackSignal = new AbortController().signal + await expect(options.canUseTool!( + 'Bash', + { command: 'cat /private/secret.txt', token: 'SECRET_TOKEN' }, + { + signal: callbackSignal, + toolUseID: 'tool-1', + requestId: 'request-1', + blockedPath: '/private/secret.txt', + decisionReason: 'SECRET_TOKEN in /private/secret.txt', + }, + )).resolves.toEqual({ + behavior: 'deny', + message: 'This unattended Claude Code subagent cannot request human approval.', + }) + await expect(options.onElicitation!( + { + serverName: 'private-server', + message: 'enter SECRET_TOKEN', + requestedSchema: { secret: true }, + }, + { signal: callbackSignal }, + )).resolves.toEqual({ action: 'decline' }) + await expect(options.onUserDialog!( + { + dialogKind: 'refusal_fallback_prompt', + payload: { path: '/private/secret.txt', token: 'SECRET_TOKEN' }, + }, + { signal: callbackSignal }, + )).resolves.toEqual({ behavior: 'cancelled' }) + expect(diagnostics).toEqual([ + 'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval', + 'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + 'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs', + ]) + expect(diagnostics.join('\n')).not.toContain('SECRET_TOKEN') + expect(diagnostics.join('\n')).not.toContain('/private/secret.txt') const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions()) expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess) @@ -585,6 +859,46 @@ describe('query options and result mapping', () => { })) }) + it.each(CLAUDE_CODE_PERMISSION_MODES)( + 'maps the %s mode and only confirms the dangerous bypass', + (permissionMode) => { + const child = fakeChild() + const options = claudeQueryOptions({ + cwd: '/workspace', + permissionMode, + env: {}, + disposeGraceMs: 17, + spawn: () => child.handle, + }, new AbortController(), () => {}, () => {}) + expect(options.permissionMode).toBe(permissionMode) + expect(options.disallowedTools).toEqual(permissionMode === 'plan' + ? ['AskUserQuestion', 'ExitPlanMode'] + : ['AskUserQuestion']) + if (permissionMode === 'bypassPermissions') { + expect(options.allowDangerouslySkipPermissions).toBe(true) + expect(options).not.toHaveProperty('canUseTool') + } else { + expect(options).not.toHaveProperty('allowDangerouslySkipPermissions') + expect(options.canUseTool).toBeTypeOf('function') + } + }, + ) + + it('disallows ExitPlanMode before native plan-mode allow rules', () => { + const child = fakeChild() + const options = claudeQueryOptions({ + cwd: '/workspace', + permissionMode: 'plan', + env: {}, + disposeGraceMs: 17, + spawn: () => child.handle, + }, new AbortController(), () => {}, () => {}) + expect(options.disallowedTools).toEqual([ + 'AskUserQuestion', + 'ExitPlanMode', + ]) + }) + it('accepts only a non-error success with a non-blank final result', () => { expect(successfulResult(success('exact final'))).toBe('exact final') expect(() => successfulResult(success('answer', true))) @@ -614,6 +928,16 @@ describe('query options and result mapping', () => { await expect(consumeClaudeQuery( queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), )).rejects.toThrow('ended without a result') + + const onPermissionDenied = vi.fn() + await expect(consumeClaudeQuery(queryFrom([ + permissionDenied(), + success('after denial'), + ]), onPermissionDenied)).resolves.toEqual({ + output: [{ type: 'text', text: 'after denial' }], + stopReason: 'completed', + }) + expect(onPermissionDenied).toHaveBeenCalledOnce() }) }) @@ -667,6 +991,61 @@ describe('run publication, cancellation, and settlement', () => { } }) + it('attaches a safe diagnostic when a permission denial precedes failure', async () => { + const fixture = fakeRun([ + permissionDenied(), + failure('error_during_execution'), + ]) + const run = await startClaudeCodeRun(request(), fixture.spec) + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + expect(result.diagnostic).not.toContain('/private/secret.txt') + await run.dispose() + }) + + it('omits captured diagnostics on success and isolates concurrent runs', async () => { + const children = [fakeChild(), fakeChild()] + let childIndex = 0 + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + permissionMode: 'dontAsk', + env: {}, + disposeGraceMs: 5, + spawn: () => children[childIndex++]!.handle, + } + queryMock.mockImplementation(({ prompt, options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'denied then completed' + ? queryFrom([permissionDenied(), success('completed answer')]) + : queryFrom([failure('error_during_execution')]) + }) + + const [completed, failed] = await Promise.all([ + startClaudeCodeRun( + request([{ type: 'text', text: 'denied then completed' }]), + spec, + ), + startClaudeCodeRun( + request([{ type: 'text', text: 'unrelated failure' }]), + spec, + ), + ]) + await expect(completed.result).resolves.toEqual({ + output: [{ type: 'text', text: 'completed answer' }], + stopReason: 'completed', + }) + await expect(failed.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await Promise.all([completed.dispose(), failed.dispose()]) + }) + it('fails closed when iteration rejects after a result', async () => { const fixture = fakeRun( [success('partial final')], @@ -703,7 +1082,7 @@ describe('run publication, cancellation, and settlement', () => { let index = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', + permissionMode: 'dontAsk', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -754,7 +1133,7 @@ describe('run publication, cancellation, and settlement', () => { request(undefined, parentAbort.signal), { cwd: '/workspace', - executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: {}, disposeGraceMs: 5, spawn: () => child.handle, @@ -842,14 +1221,101 @@ describe('run publication, cancellation, and settlement', () => { expect(factoryController?.signal.aborted).toBe(true) expect(spawned.terminate).toHaveBeenCalledOnce() + const spawnError = Object.assign( + new Error('spawn /sdk/claude EACCES'), + { code: 'EACCES', path: '/sdk/claude' }, + ) const failedSpawn = fakeChild({ pid: -1, - doneError: new Error('spawn failed'), + doneError: spawnError, }) const failed = fakeRun([], undefined, failedSpawn) await expect(startClaudeCodeRun(request(), failed.spec)) - .rejects.toBeInstanceOf(AggregateError) + .rejects.toBe(spawnError) expect(failed.close).toHaveBeenCalledOnce() + expect(failedSpawn.terminate).not.toHaveBeenCalled() + expect(failedSpawn.waitForExit).not.toHaveBeenCalled() + + const failedSpawnAbort = new AbortController() + const cancelledFailedSpawn = fakeChild({ + pid: -1, + doneError: spawnError, + }) + const cancelledFailedClose = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + failedSpawnAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, cancelledFailedClose) + }) + await expect(startClaudeCodeRun( + request(undefined, failedSpawnAbort.signal), + { ...unused.spec, spawn: () => cancelledFailedSpawn.handle }, + )).rejects.toThrow('aborted before SDK startup') + expect(cancelledFailedClose).toHaveBeenCalledOnce() + + const cancelledFailedSpawnCloseError = new Error('cancelled query close failed') + const cancelledFailedSpawnClose = vi.fn(() => { + throw cancelledFailedSpawnCloseError + }) + const cancelledFailedSpawnWithCloseFailure = fakeChild({ + pid: -1, + doneError: spawnError, + }) + const failedSpawnAbortWithCloseFailure = new AbortController() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + failedSpawnAbortWithCloseFailure.abort(new Error('startup cancelled')) + return queryFrom([], undefined, cancelledFailedSpawnClose) + }) + const cancelledWithCloseFailure = startClaudeCodeRun( + request(undefined, failedSpawnAbortWithCloseFailure.signal), + { ...unused.spec, spawn: () => cancelledFailedSpawnWithCloseFailure.handle }, + ) + await expect(cancelledWithCloseFailure).rejects.toMatchObject({ + message: 'subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: spawn /sdk/claude EACCES; query cleanup also failed', + errors: [ + expect.objectContaining({ message: 'subagent-claude-code: request was aborted before SDK startup' }), + spawnError, + cancelledFailedSpawnCloseError, + ], + }) + expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce() + + const failedSpawnCloseError = new Error('query close failed') + const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) + const failedSpawnWithCloseFailure = fakeChild({ + pid: -1, + doneError: spawnError, + }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return queryFrom([], undefined, failedSpawnClose) + }) + const failedWithCloseFailure = startClaudeCodeRun(request(), { + ...unused.spec, + spawn: () => failedSpawnWithCloseFailure.handle, + }) + await expect(failedWithCloseFailure) + .rejects.toThrow('spawn /sdk/claude EACCES') + await expect(failedWithCloseFailure).rejects.toMatchObject({ + errors: [spawnError, failedSpawnCloseError], + }) + + const cleanupError = new Error('live child cleanup failed') + const constructionError = new Error( + 'query construction failed with a live child', + ) + const liveChildCleanupFailure = fakeChild({ doneError: cleanupError }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw constructionError + }) + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + spawn: () => liveChildCleanupFailure.handle, + })).rejects.toMatchObject({ + errors: [constructionError, cleanupError], + }) }) }) diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da14b8ff30..4bdea58bdf 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: 848d170585710b682fa4ce331010fce7080de673 -README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c +README.md: 1b9f777c26fa2e1ee050dfc73bdb648e66e93043 +README.zh.md: f70510dd680a78193ebb45e95b9e666de59db571 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 848d170585..1b9f777c26 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers a Profile-named Codex subagent provider whose default name is `codex`. Each accepted run starts the official package-local Codex wrapper with `app-server --stdio` in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns either the selected final answer or a separate safe failure diagnostic through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. -For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. The wire records only the effective mode, request category, decision, and fixed safe reason. It also recognizes declined command/file items and `sandboxError` terminals. Codex 0.147.0 writes some early `never` rejections and sandbox violations only to structured stderr, so the Provider pipes stderr, forwards it unchanged to the host, and matches two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. -Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. A permission-related error may additionally carry the bounded, non-assistant `SubagentResult.diagnostic`; successful and locally cancelled runs omit it. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Result failure and independent teardown failure remain separate. ## Capabilities and context @@ -22,40 +22,83 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| +| `providerName` | `codex` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. +| `permissionMode` value | `thread/start` fields | Native behavior | +|---|---|---| +| `never` | `approvalPolicy: never`; sandbox omitted | Never ask for approval; execution failures return to the model under the native sandbox. | +| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review without a human. | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement; this value must be selected explicitly. | -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +Production resolves the `codex` bin declared by its pinned `@openai/codex@0.147.0` dependency and launches that JavaScript wrapper with the current Node executable. The wrapper selects the matching native platform payload; the provider neither inspects nor falls back to a host `codex` on `PATH`. Native Codex configuration and authentication remain authoritative through the parent cwd, `HOME`, and `CODEX_HOME`, while the Provider overrides only the selected thread approval/reviewer/sandbox fields. All other project, model, provider, MCP, hook, skill, and account settings remain native. The plugin does not select a model, create `CODEX_HOME`, log in, or probe an account. Credential-shaped ambient variables are removed by the subprocess seam before the explicit `env` overlay is applied. -The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services. +This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; installation brings the official wrapper and one compatible native platform payload into that Profile, while the declared `cordis.patch.yml` layer registers only the dormant `codex` Host provider and starts no Codex process. Removing the package withdraws that provider and its private runtime closure on the next Profile start. + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh --profile +``` + +Installation controls Host availability, not model permission. The Bundle supplies the dormant default `codex` row; the Profile may replace that row's complete config or mount additional rows with distinct `providerName`, `permissionMode`, and `env` values. Loading an instance starts no Codex process until a bound tool calls it. Each `dsh-tool-subagent` row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. + +The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider and tool rows, and does not mount duplicate Job services. ```yaml -- id: subagent-codex +- id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: + providerName: codex-safe + permissionMode: never env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +- id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +``` + +```yaml - id: jobs name: '@deepseek-ai/dsh-jobs-local' - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-codex +- id: tool-subagent-codex-safe + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-codex-bypass name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. The runtime dependency and all six optional-dependency aliases are pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`. A normal install selects one payload for the current OS and CPU. For the current darwin-arm64 payload, `npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` reports 111,199,052 packed bytes and 274,777,843 unpacked bytes. That package contains native `codex`, `codex-code-mode-host`, `rg`, and `zsh` resources; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test drives the package wrapper against a loopback Responses fixture, observes the package-local argv, and proves wrapper and native descendants become quiescent. + +Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload makes the first delegation fail with the wrapper's native-payload startup error. The provider neither probes a host CLI nor retries with one. + +Real-product coverage additionally proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits. + +The same real-product tier proves that two named instances retain separate environments and native modes. ## Model Experience @@ -63,7 +106,7 @@ The production wire intentionally implements only the app-server methods require #### What the model sees -The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd; its model, system instructions, tools, and authentication come from native Codex configuration, the selected Provider instance's Profile configuration fixes the thread's environment, non-interactive approval policy, and sandbox mode, and the executable version comes from the Bundle's pinned platform payload. #### Token effect @@ -77,7 +120,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. #### Token effect @@ -90,9 +133,11 @@ Append-only: foreground adds one result after the reusable parent prefix, while ## Known Limitations and Deferred Work - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. -- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`. +- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, trust a project, or rewrite Codex settings; configuration and authentication failures surface as startup or run errors. +- **The native platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first run; there is no host-CLI fallback. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. -- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. -- **Product payload is final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. +- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. +- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 34e9105e6a..f70510dd68 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包注册由 Profile 命名、默认名称为 `codex` 的 Codex subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中使用 `app-server --stdio` 启动官方包内 Codex wrapper,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或独立的安全失败诊断。 ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 -对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。wire 只记录有效模式、请求类别、决定与固定的安全原因,也会识别被拒绝的命令/文件 item 和 `sandboxError` 终态。Codex 0.147.0 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe stderr、原样转发给 Host,并在每次运行的有界尾缓冲中匹配两个固定签名;原始 stderr 不会进入诊断。 -本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。权限相关错误可以额外携带有界、非 assistant 的 `SubagentResult.diagnostic`;成功和本地取消不会附带它。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。结果失败与独立的清理失败仍彼此分离。 ## 能力与上下文 @@ -22,40 +22,83 @@ | 配置键 | 默认值 | 含义 | |---|---|---| +| `providerName` | `codex` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 +| `permissionMode` 值 | `thread/start` 字段 | 原生行为 | +|---|---|---| +| `never` | `approvalPolicy: never`;省略 sandbox | 永不请求审批;执行失败会在原生 sandbox 下返回模型。 | +| `approve-for-me` | `approvalPolicy: on-request`、`approvalsReviewer: auto_review`、`sandbox: workspace-write` | 由 Codex 自动评审权限请求,不等待人工。 | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`、`sandbox: danger-full-access` | 跳过审批与 sandbox;必须显式选择该值。 | -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +生产环境会解析锁定的 `@openai/codex@0.147.0` 依赖所声明的 `codex` bin,并使用当前 Node 可执行文件启动该 JavaScript wrapper。Wrapper 会选择匹配的原生平台载荷;提供方既不检查也不回退 `PATH` 中的宿主 `codex`。父会话 cwd、`HOME` 与 `CODEX_HOME` 继续让原生 Codex 配置和身份验证保持权威,而提供方只覆盖选定线程的 approval/reviewer/sandbox 字段。其他项目、模型、provider、MCP、hook、skill 与账户设置仍由原生机制负责。本插件不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测账户。子进程 seam 会先移除具有凭证特征的环境变量,再应用显式 `env` 覆盖。 -下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。 +本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;安装会把官方 wrapper 与一个兼容的原生平台载荷带入该 Profile,而包所声明的 `cordis.patch.yml` 层只注册休眠的 `codex` Host provider,不会启动 Codex 进程。移除该包后,下一次 Profile 启动会撤回这一 provider 及其私有运行时闭包。 + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh --profile +``` + +安装决定 Host 可用性,而不是模型权限。Bundle 会提供休眠的默认 `codex` 配置项;Profile 可以替换该配置项的完整 config,也可以挂载更多具有不同 `providerName`、`permissionMode` 与 `env` 的配置项。加载实例本身不会在绑定工具调用前启动 Codex 进程。每个 `dsh-tool-subagent` 配置项指定一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 + +下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 配置项,新增产品提供方与工具配置项,而且不重复挂载 Job 服务。 ```yaml -- id: subagent-codex +- id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: + providerName: codex-safe + permissionMode: never env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +- id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +``` + +```yaml - id: jobs name: '@deepseek-ai/dsh-jobs-local' - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-codex +- id: tool-subagent-codex-safe + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-codex-bypass name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。运行时依赖与六个 optional-dependency alias 均锁定到 `@openai/codex@0.147.0` / `codex-cli 0.147.0`。普通安装会按当前操作系统与 CPU 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` 报告压缩包为 111,199,052 字节、解包后为 274,777,843 字节。该包包含原生 `codex`、`codex-code-mode-host`、`rg` 与 `zsh` 资源;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会驱动包内 wrapper 连接回环 Responses fixture,观测包内 argv,并证明 wrapper 与原生后代进程完全停稳。 + +如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,第一次委派会以 wrapper 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试。 + +真实产品覆盖还会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 + +同一真实产品层级还会证明两个命名实例保留彼此独立的环境与原生模式。 ## 模型体验 @@ -63,7 +106,7 @@ #### 模型看到的内容 -Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 +Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 配置,所选提供方实例的 Profile 配置会固定该线程的环境、非交互审批策略与沙箱模式,而可执行版本来自 Bundle 锁定的平台载荷。 #### 对 token 的影响 @@ -77,7 +120,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -90,9 +133,11 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 ## 已知限制与后续工作 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 -- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`。 +- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录、信任项目或改写 Codex 设置;配置与身份验证失败会呈现为启动错误或运行错误。 +- **委派时必须存在原生平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次运行时失败;不会回退到宿主 CLI。 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 -- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 -- **产品载荷仅包含最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。 +- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 +- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/cordis.patch.yml b/packages/subagent/subagent-codex/cordis.patch.yml new file mode 100644 index 0000000000..fb64fdcb9d --- /dev/null +++ b/packages/subagent/subagent-codex/cordis.patch.yml @@ -0,0 +1,5 @@ +# Optional Profile Bundle: register the Codex provider on the Host plane only. +# Agent Presets grant the model-facing tool independently. +- insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 29493a5612..dbe30e18e1 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -28,13 +28,18 @@ "files": [ "lib/index.js", "lib/invariant.js", + "cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", @@ -42,7 +47,9 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "@openai/codex": "0.147.0" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -50,13 +57,11 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@openai/codex": "0.147.0", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 3b1bbec799..d94197edf7 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -1,7 +1,7 @@ /** - * Fixed Codex one-shot subagent provider. Every accepted run starts a fresh - * official `codex app-server --stdio` process in the delegating Session's - * workspace and publishes only after an ephemeral thread exists. + * Profile-named Codex one-shot subagent provider. Every accepted run starts a + * fresh official package-local Codex wrapper with `app-server --stdio` in the + * delegating Session's workspace and publishes only after an ephemeral thread exists. * * @module @deepseek-ai/dsh-subagent-codex */ @@ -18,38 +18,50 @@ import { type SubagentProvider, } from '@deepseek-ai/dsh-subagent' import { + CODEX_PERMISSION_MODES, + DEFAULT_CODEX_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, startCodexRun, + type CodexPermissionMode, type CodexRunSpec, } from './run.ts' export const name = 'subagent-codex' export const inject = ['subagents', 'subprocess'] -/** Deployment-owned environment and process-release bound. */ +const DEFAULT_PROVIDER_NAME = 'codex' + +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `codex`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: CodexPermissionMode /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } export const Config: z = z.object({ + providerName: z.string().min(1).default(DEFAULT_PROVIDER_NAME), env: z.dict(z.string()).default({}), + permissionMode: z.union([...CODEX_PERMISSION_MODES]) + .default(DEFAULT_CODEX_PERMISSION_MODE), disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) type ResolvedConfig = Required class CodexProvider implements SubagentProvider { - readonly name = 'codex' readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES readonly inheritsParentContext = false constructor( + readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig, ) {} @@ -67,12 +79,13 @@ class CodexProvider implements SubagentProvider { undefined, parentCwd, ), + permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), onError: (error, stopReason) => { this.ctx.logger.warn( - `subagent-codex: child run failed (${stopReason}): ${error.message}`, + `subagent-codex "${this.name}": child run failed (${stopReason}): ${error.message}`, ) }, } @@ -81,12 +94,17 @@ class CodexProvider implements SubagentProvider { } /** - * Register the fixed `codex` provider. + * Register one Profile-named Codex provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - explicit child environment and disposal grace. + * @param config - registry name, permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const resolved: ResolvedConfig = { + providerName: config.providerName ?? DEFAULT_PROVIDER_NAME, + env: config.env as Record, + permissionMode: config.permissionMode ?? DEFAULT_CODEX_PERMISSION_MODE, + disposeGraceMs: config.disposeGraceMs as number, + } assertPositiveFinite( 'subagent-codex', 'disposeGraceMs', @@ -97,5 +115,9 @@ export function apply(ctx: Context, config: Config): void { `subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, ) } - ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) + ctx.subagents.registerProvider(new CodexProvider( + resolved.providerName, + ctx, + resolved, + )) } diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index ebce244f3b..3d9693b4e1 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -8,6 +8,9 @@ */ import { randomUUID } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -23,28 +26,73 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Bounded stderr tail retained only to recognize the wrapper's payload error. */ +const CODEX_STDERR_TAIL_BYTES = 16 * 1024 + +interface CodexPackageManifest { + readonly bin: { + readonly codex: string + } +} + +const codexPackageJsonPath = createRequire(import.meta.url).resolve('@openai/codex/package.json') +const codexPackageManifest = JSON.parse( + readFileSync(codexPackageJsonPath, 'utf8'), +) as CodexPackageManifest + +/** Absolute package-local JavaScript wrapper selected by the package manifest. */ +const CODEX_PACKAGE_BIN = resolve( + dirname(codexPackageJsonPath), + codexPackageManifest.bin.codex, +) + +function missingPayloadDiagnostic(stderr: string): string | undefined { + const platformPackage = /Missing optional dependency (@openai\/codex-[a-z0-9-]+)/ + .exec(stderr)?.[1] + return platformPackage === undefined + ? undefined + : `Missing optional dependency ${platformPackage}` +} + +function withMissingPayloadDiagnostic( + error: Error, + stderr: string, +): Error { + const diagnostic = missingPayloadDiagnostic(stderr) + if (diagnostic === undefined || error.message.includes(diagnostic)) return error + return new Error(`${error.message}: ${diagnostic}`, { cause: error }) +} + +/** Profile-selectable non-interactive Codex permission mode. */ +export type CodexPermissionMode = + | 'never' + | 'approve-for-me' + | 'dangerously-bypass-approvals-and-sandbox' + +/** Native non-interactive Codex modes mapped to official `thread/start` fields. */ +export const CODEX_PERMISSION_MODES = [ + 'never', + 'approve-for-me', + 'dangerously-bypass-approvals-and-sandbox', +] as const satisfies readonly CodexPermissionMode[] + +/** Safe default for unattended Codex runs. */ +export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never' /** - * Resolve the fixed app-server command for a platform. - * - * Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`; - * the argv is constant so no task or configuration text enters the - * shell boundary. - * @param platform - host platform used to select the executable boundary. - * @returns argv for the fixed Codex app-server command. + * Fixed package-local app-server command, independent of the host `PATH`. + * @returns Node, the official wrapper, and the fixed app-server arguments. */ -export function codexAppServerArgv( - platform: NodeJS.Platform = process.platform, -): string[] { - return platform === 'win32' - ? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio'] - : ['codex', 'app-server', '--stdio'] +export function codexAppServerArgv(): string[] { + return [process.execPath, CODEX_PACKAGE_BIN, 'app-server', '--stdio'] } /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ readonly cwd: string + /** Profile-selected native non-interactive permission mode. */ + readonly permissionMode: CodexPermissionMode /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -125,7 +173,7 @@ export async function startCodexRun( const child = spec.spawn({ argv: codexAppServerArgv(), cwd: spec.cwd, - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: spec.disposeGraceMs, env: spec.env, }) @@ -133,8 +181,41 @@ export async function startCodexRun( const wire = new CodexAppServerWire( child.stdout as NonNullable, child.stdin as NonNullable, + spec.permissionMode, ) - const disposeProcess = (): Promise => disposeCodexChild(wire, child) + let stderrTail = Buffer.alloc(0) + const onStderr = (chunk: Buffer | string): void => { + const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk + const combined = Buffer.concat([stderrTail, bytes]) + stderrTail = combined.length > CODEX_STDERR_TAIL_BYTES + ? Buffer.from(combined.subarray(combined.length - CODEX_STDERR_TAIL_BYTES)) + : combined + wire.observeStderr(bytes.toString()) + try { + // Synchronous fd forwarding preserves byte order without owning a + // backpressure queue. A slow host sink can block this event-loop turn. + writeFileSync(process.stderr.fd, bytes) + } catch { + // Host stderr is an observation sink, not a child-run failure authority. + } + } + const onStderrError = (): void => { + // Stderr observation is auxiliary. JSON-RPC and child.done remain the + // only terminal authorities if the diagnostic stream itself fails. + } + child.stderr?.on('data', onStderr) + child.stderr?.on('error', onStderrError) + const disposeProcess = async (): Promise => { + try { + await disposeCodexChild(wire, child) + // Let stderr already queued by the process close reach both bounded + // diagnostic consumers before their listeners are detached. + await new Promise((resolve) => { setImmediate(resolve) }) + } finally { + child.stderr?.off('data', onStderr) + child.stderr?.off('error', onStderrError) + } + } const processFailure: Promise = child.done.then( outcome => Promise.reject(new Error( @@ -162,27 +243,38 @@ export async function startCodexRun( await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) + const startupCause = thrown(error) try { await disposeProcess() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [withMissingPayloadDiagnostic(startupCause, stderrTail.toString()), thrown(disposeError)], 'subagent-codex: startup failed and app-server cleanup also failed', ) } if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before run publication') } - throw thrown(error) + throw withMissingPayloadDiagnostic(startupCause, stderrTail.toString()) } const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ - attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal), - processFailure, - ]), + attempt: async () => { + try { + return await Promise.race([ + wire.runTurn(texts, runAbort.signal), + processFailure, + ]) + } catch (error: unknown) { + // Give stderr data already queued in Node one turn to reach the wire + // before settlement snapshots the diagnostic; later OS data is best-effort. + await new Promise((resolve) => { setImmediate(resolve) }) + throw withMissingPayloadDiagnostic(thrown(error), stderrTail.toString()) + } + }, collectOutput, + collectDiagnostic: () => wire.collectDiagnostic(), cancelled: () => runAbort.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index bc00ff0acf..cd94695863 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -11,9 +11,57 @@ import type { Readable, Writable } from 'node:stream' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult } from '@deepseek-ai/dsh-subagent' import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' +import type { CodexPermissionMode } from './run.ts' type JsonObject = Record +const THREAD_PERMISSION_PARAMS: Readonly> = { + never: { approvalPolicy: 'never' }, + 'approve-for-me': { + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }, + 'dangerously-bypass-approvals-and-sandbox': { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + }, +} + +const STDERR_PERMISSION_SIGNATURES = [ + { + text: 'approval policy is Never; reject command', + request: 'command execution', + decision: 'denied', + reason: 'Codex rejected an escalation because the selected policy never asks for approval', + }, + { + text: 'recorded sandbox violation:', + request: 'sandbox execution', + decision: 'failed', + reason: 'Codex reported a sandbox violation', + }, +] as const + +const STDERR_SIGNATURE_TAIL_CHARS = Math.max( + ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length), +) - 1 + +function stderrSignatureTail(value: string): string { + for ( + let length = Math.min(STDERR_SIGNATURE_TAIL_CHARS, value.length) + ; length > 0 + ; length -= 1 + ) { + const tail = value.slice(-length) + if (STDERR_PERMISSION_SIGNATURES.some(signature => + tail.length < signature.text.length && signature.text.startsWith(tail))) { + return tail + } + } + return '' +} + function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -47,6 +95,24 @@ function isContextWindowExceeded(turn: JsonObject): boolean { && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' } +function isSandboxFailure(turn: JsonObject): boolean { + if (turn.status !== 'failed') return false + const error = turn.error + return error !== null + && typeof error === 'object' + && !Array.isArray(error) + && (error as JsonObject).codexErrorInfo === 'sandboxError' +} + +function unattendedDiagnostic( + mode: CodexPermissionMode, + request: 'command approval' | 'file approval' | 'permission grant' | 'user input' | 'MCP elicitation' | 'command execution' | 'file change' | 'sandbox execution', + decision: 'cancelled' | 'declined' | 'denied' | 'empty response' | 'failed', + reason: string, +): string { + return `Codex unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}` +} + function thrown(value: unknown): Error { /* v8 ignore next -- typed protocol and stream failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -86,18 +152,33 @@ export class CodexAppServerWire { private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined - private turnCompleted: PromiseWithResolvers | undefined + private turnCompleted: PromiseWithResolvers<{ + readonly params: JsonObject + readonly order: number + }> | undefined private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject + readonly order: number }> = [] private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined + private diagnostic: string | undefined + private diagnosticOrder = 0 + private observationOrder = 0 + private pendingDiagnostic: { + readonly order: number + readonly request: Parameters[1] + readonly decision: Parameters[2] + readonly reason: string + } | undefined + private stderrTail = '' private closed = false constructor( private readonly input: Readable, output: Writable, + private readonly permissionMode: CodexPermissionMode, ) { this.transport = new JsonRpcLineTransport(input, output) // Fatal protocol state can arrive after the current guarded operation has @@ -154,6 +235,7 @@ export class CodexAppServerWire { const response = object(await this.guarded(this.transport.request('thread/start', { cwd, ephemeral: true, + ...THREAD_PERMISSION_PARAMS[this.permissionMode], }, signal), signal), 'thread/start response') const thread = object(response.thread, 'thread/start thread') const id = string(thread.id, 'thread/start thread id') @@ -174,7 +256,10 @@ export class CodexAppServerWire { texts: readonly string[], signal: AbortSignal, ): Promise { - const completion = Promise.withResolvers() + const completion = Promise.withResolvers<{ + readonly params: JsonObject + readonly order: number + }>() this.turnCompleted = completion const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { @@ -185,14 +270,25 @@ export class CodexAppServerWire { this.commitTurnId(string(turn.id, 'turn/start turn id')) const completed = await this.guarded(completion.promise, signal) - const terminal = object(completed.turn, 'turn/completed turn') + const terminal = object(completed.params.turn, 'turn/completed turn') const status = terminal.status if (isContextWindowExceeded(terminal)) { return { output: this.collectOutput(), stopReason: 'max-tokens' } } if (status !== 'completed') { + const sandboxFailure = isSandboxFailure(terminal) + if (sandboxFailure) { + this.recordDiagnostic( + 'sandbox execution', + 'failed', + 'Codex reported a sandbox failure', + completed.order, + ) + } const detail = status === 'failed' - ? `: ${JSON.stringify(terminal.error)}` + ? sandboxFailure + ? ': sandboxError' + : ': error' : '' throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) } @@ -226,6 +322,36 @@ export class CodexAppServerWire { : [] } + /** + * The latest safe unattended permission fact observed for this run. + * @returns provider-authored diagnostic text, when one was observed. + */ + collectDiagnostic(): string | undefined { + return this.diagnostic + } + + /** + * Observe product stderr while retaining only enough tail to recognize fixed + * permission signatures. The raw text is never copied into the diagnostic. + * @param chunk - one decoded stderr chunk already forwarded to the host. + */ + observeStderr(chunk: string): void { + const observed = `${this.stderrTail}${chunk}` + let latestIndex = -1 + let latest: (typeof STDERR_PERMISSION_SIGNATURES)[number] | undefined + for (const signature of STDERR_PERMISSION_SIGNATURES) { + const index = observed.lastIndexOf(signature.text) + if (index > latestIndex) { + latestIndex = index + latest = signature + } + } + if (latest !== undefined) { + this.recordDiagnostic(latest.request, latest.decision, latest.reason) + } + this.stderrTail = stderrSignatureTail(observed) + } + /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ close(): void { if (this.closed) return @@ -270,42 +396,162 @@ export class CodexAppServerWire { throw new Error('subagent-codex: turn/start response did not match the active turn') } this.turnId = id + const pendingDiagnostic = this.pendingDiagnostic + this.pendingDiagnostic = undefined + if (pendingDiagnostic !== undefined) { + this.recordDiagnostic( + pendingDiagnostic.request, + pendingDiagnostic.decision, + pendingDiagnostic.reason, + pendingDiagnostic.order, + ) + } const notifications = this.earlyTurnNotifications.splice(0) for (const notification of notifications) { - this.handleNotification(notification.method, notification.params) + this.handleNotification( + notification.method, + notification.params, + notification.order, + ) } } - private validateRunIds(params: JsonObject, nullableTurn = false): void { + /** + * Validate the request's thread and turn association. + * @returns `true` when the matching turn is still provisional, so the caller + * defers its diagnostic until `commitTurnId()`. + */ + private validateRunIds( + params: JsonObject, + nullableTurn = false, + ): boolean { if (params.threadId !== this.threadId) { throw new Error('subagent-codex: app-server request referenced another thread') } - if (nullableTurn && params.turnId === null) return + if (nullableTurn && params.turnId === null) return false const id = string(params.turnId, 'server request turn id') if (this.turnId === undefined) { this.observePendingTurnId(id) - return + return true } if (id !== this.turnId) { throw new Error('subagent-codex: app-server request referenced another turn') } + return false + } + + private recordRequestDiagnostic( + provisional: boolean, + request: Parameters[1], + decision: Parameters[2], + reason: string, + ): void { + const order = this.nextObservationOrder() + if (provisional) { + this.pendingDiagnostic = { + order, + request, + decision, + reason, + } + return + } + this.recordDiagnostic(request, decision, reason, order) + } + + private recordDiagnostic( + request: Parameters[1], + decision: Parameters[2], + reason: string, + order = this.nextObservationOrder(), + ): void { + if (order < this.diagnosticOrder) return + this.diagnosticOrder = order + this.diagnostic = unattendedDiagnostic( + this.permissionMode, + request, + decision, + reason, + ) + } + + private nextObservationOrder(): number { + this.observationOrder += 1 + return this.observationOrder + } + + private recordDeclinedItem(item: JsonObject, order?: number): boolean { + if (item.type === 'commandExecution' && item.status === 'declined') { + this.recordDiagnostic( + 'command execution', + 'declined', + 'Codex declined the command under the selected permission mode', + order, + ) + return true + } + if (item.type === 'fileChange' && item.status === 'declined') { + this.recordDiagnostic( + 'file change', + 'declined', + 'Codex declined the file change under the selected permission mode', + order, + ) + return true + } + return false } private handleServerRequest(method: string, params: JsonObject): Promise { try { switch (method) { case 'item/commandExecution/requestApproval': + { + const provisional = this.validateRunIds(params) + const decision = unattendedDecision(params) + this.recordRequestDiagnostic( + provisional, + 'command approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/fileChange/requestApproval': - this.validateRunIds(params) - return Promise.resolve({ decision: unattendedDecision(params) }) + { + const provisional = this.validateRunIds(params) + const decision = unattendedDecision(params) + this.recordRequestDiagnostic( + provisional, + 'file approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/permissions/requestApproval': - this.validateRunIds(params) + this.recordRequestDiagnostic( + this.validateRunIds(params), + 'permission grant', + 'denied', + 'the provider grants no additional turn permissions', + ) return Promise.resolve({ permissions: {}, scope: 'turn' }) case 'item/tool/requestUserInput': - this.validateRunIds(params) + this.recordRequestDiagnostic( + this.validateRunIds(params), + 'user input', + 'empty response', + 'the provider does not collect interactive answers', + ) return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': - this.validateRunIds(params, true) + this.recordRequestDiagnostic( + this.validateRunIds(params, true), + 'MCP elicitation', + 'declined', + 'the provider does not collect interactive MCP input', + ) return Promise.resolve({ action: 'decline', content: null, _meta: null }) default: throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) @@ -317,7 +563,11 @@ export class CodexAppServerWire { } } - private handleNotification(method: string, params: JsonObject): void { + private handleNotification( + method: string, + params: JsonObject, + order?: number, + ): void { if (method === 'turn/started') { const threadId = string(params.threadId, 'turn/started thread id') if (threadId !== this.threadId) return @@ -334,12 +584,17 @@ export class CodexAppServerWire { if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { this.observePendingTurnId(id) - this.earlyTurnNotifications.push({ method, params }) + this.earlyTurnNotifications.push({ + method, + params, + order: this.nextObservationOrder(), + }) } return } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') + if (this.recordDeclinedItem(item, order)) return if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text @@ -362,13 +617,20 @@ export class CodexAppServerWire { if (turnCompleted === undefined) return if (this.turnId === undefined) { this.observePendingTurnId(id) - this.earlyTurnNotifications.push({ method, params }) + this.earlyTurnNotifications.push({ + method, + params, + order: this.nextObservationOrder(), + }) return } if (id !== this.turnId) return if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) } - turnCompleted.resolve(params) + turnCompleted.resolve({ + params, + order: order ?? this.nextObservationOrder(), + }) } } diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index 8e265c3207..b55b819989 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -12,16 +13,24 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'cordis.yml') +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + dsh?: { bundle?: { patch?: string } } +} +const bundlePatch = manifest.dsh?.bundle?.patch +if (bundlePatch === undefined) throw new Error('Codex package must declare a Bundle patch') +const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('Codex provider public Loader composition', () => { - it('loads the opt-in package, one-shot task tool, and job controls without starting Codex', async () => { + it('loads the Bundle default, two named instances, their tools, and job controls without starting Codex', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'subagent-codex Loader composition', tempDirPrefix: 'dsh-subagent-codex-loader-', binScript: driver, libBinScript: driver, configPath, + binArgs: [configPath, bundlePatchPath], tsconfigPath: repoTsconfig, env: { // Loading the optional package must not probe or start a Codex binary. @@ -31,22 +40,56 @@ describe('Codex provider public Loader composition', () => { expect(stderr).toBe('') expect(JSON.parse(stdout)).toEqual({ - providers: ['codex'], - provider: { - name: 'codex', - capabilities: { - outputSchema: false, - depthLimit: false, - toolFilter: false, - persona: false, + providers: ['codex-primary', 'codex-secondary', 'codex'], + providerDetails: [ + { + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, }, - inheritsParentContext: false, - }, - tool: { - name: 'subagent_codex', - parameterNames: ['description', 'prompt', 'run_in_background'], - required: ['description', 'prompt'], - }, + { + name: 'codex-primary', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + { + name: 'codex-secondary', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + ], + tools: [ + { + name: 'subagent_codex', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_codex_primary', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_codex_secondary', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + ], jobTools: ['job_kill', 'job_list', 'job_output'], starts: 0, }) diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index 075b6048f2..c12e96a306 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -7,9 +7,9 @@ import { rmSync, writeFileSync, } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { delimiter, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { dirname, join, resolve } from 'node:path' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -24,12 +24,12 @@ import { } from './deepseek-responses-bridge.ts' const execFileAsync = promisify(execFile) -const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) -const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackageJson = createRequire(import.meta.url).resolve('@openai/codex/package.json') const codexPackage = JSON.parse(readFileSync( - join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + codexPackageJson, 'utf8', -)) as { version: string } +)) as { version: string; bin: { codex: string } } +const codexEntry = resolve(dirname(codexPackageJson), codexPackage.bin.codex) const roots: string[] = [] const contexts: Context[] = [] @@ -88,7 +88,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( CODEX_HOME: codexHome, HOME: root, XDG_CONFIG_HOME: join(root, 'xdg-config'), - PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + PATH: root, HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', @@ -106,7 +106,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( return handle }) await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) - const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...env }, }) expect(codexPackage.version).toBe('0.147.0') diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 551d6db765..3b95de4f79 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' import { + cpSync, existsSync, mkdirSync, mkdtempSync, @@ -7,17 +8,19 @@ import { writeFileSync, } from 'node:fs' import { rm } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { delimiter, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' +import type { CodexPermissionMode } from '../src/run.ts' import { startResponsesFixture, type ResponsesBehavior, @@ -27,11 +30,13 @@ import { const execFileAsync = promisify(execFile) const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) const codexBinDir = join(packageRoot, 'node_modules', '.bin') -const codexEntry = join(packageRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js') +const codexPackageJson = createRequire(import.meta.url).resolve('@openai/codex/package.json') const codexPackage = JSON.parse(readFileSync( - join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + codexPackageJson, 'utf8', -)) as { version: string } +)) as { version: string; bin: { codex: string } } +const codexEntry = resolve(dirname(codexPackageJson), codexPackage.bin.codex) +const codexPackageRoot = dirname(dirname(codexEntry)) const roots: string[] = [] const fixtures: ResponsesFixture[] = [] @@ -48,23 +53,31 @@ afterEach(async () => { interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent readonly env: Record readonly workspace: string } -async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ - readonly harness: RealHarness +interface RealInstanceFixture { readonly fixture: ResponsesFixture -}> { + readonly env: Record + readonly workspace: string +} + +type ResponsesScript = readonly ResponsesBehavior[] | ((workspace: string) => readonly ResponsesBehavior[]) + +async function realInstanceFixture( + script: ResponsesScript, +): Promise { const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-')) roots.push(root) const workspace = join(root, 'workspace') const codexHome = join(root, 'codex-home') - const fixture = await startResponsesFixture(script) - fixtures.push(fixture) mkdirSync(workspace) mkdirSync(codexHome) + const fixture = await startResponsesFixture(typeof script === 'function' ? script(workspace) : script) + fixtures.push(fixture) writeFileSync(join(codexHome, 'config.toml'), [ 'model = "fixture-model"', 'model_provider = "fixture"', @@ -95,23 +108,61 @@ async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ ALL_PROXY: '', NO_PROXY: '127.0.0.1,localhost', } + return { fixture, env, workspace } +} + +interface RealRuntime { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] +} + +async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] + const spawnSpecs: SubprocessSpawnSpec[] = [] const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) const handle = spawn(spec) handles.push(handle) return handle }) - await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + return { ctx, handles, spawnSpecs } +} + +async function realHarness( + script: ResponsesScript, + permissionMode?: CodexPermissionMode, +): Promise<{ + readonly harness: RealHarness + readonly fixture: ResponsesFixture +}> { + const instance = await realInstanceFixture(script) + const { ctx, handles, spawnSpecs } = await realRuntime() + await ctx.plugin(codex, { + env: instance.env, + ...permissionMode === undefined ? {} : { permissionMode }, + disposeGraceMs: 2_000, + }) const parent = { id: 'real-parent', - session: { header: { cwd: workspace } }, + session: { header: { cwd: instance.workspace } }, } as unknown as Agent - return { harness: { ctx, handles, parent, env, workspace }, fixture } + return { + harness: { + ctx, + handles, + spawnSpecs, + parent, + env: instance.env, + workspace: instance.workspace, + }, + fixture: instance.fixture, + } } async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { @@ -141,12 +192,12 @@ function responseInputTexts(body: Record): string[] { } describe('real @openai/codex 0.147.0 product', () => { - it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { + it('starts approve-for-me through the real app-server and returns exact text', async () => { const sentinel = 'REAL_CODEX_SENTINEL_0_147_0' const task = 'Return the fixture sentinel exactly.' const { harness, fixture } = await realHarness([ { kind: 'complete', text: sentinel }, - ]) + ], 'approve-for-me') expect(codexPackage.version).toBe('0.147.0') const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...harness.env }, @@ -164,6 +215,13 @@ describe('real @openai/codex 0.147.0 product', () => { }) await run.dispose() + expect(harness.spawnSpecs[0]?.argv).toEqual([ + process.execPath, + codexEntry, + 'app-server', + '--stdio', + ]) + expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! expect(recorded.method).toBe('POST') @@ -173,7 +231,98 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) - it('cancels a real app-server command approval without executing the command', async () => { + it('fails a missing platform payload without falling back to a host codex', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-missing-payload-')) + roots.push(root) + const isolatedPackage = join(root, 'node_modules', '@openai', 'codex') + mkdirSync(dirname(isolatedPackage), { recursive: true }) + cpSync(codexPackageRoot, isolatedPackage, { recursive: true, dereference: true }) + const isolatedEntry = join(isolatedPackage, 'bin', 'codex.js') + + await expect(execFileAsync(process.execPath, [isolatedEntry, '--version'], { + env: { + PATH: codexBinDir, + ...process.platform === 'win32' && process.env.SystemRoot !== undefined + ? { SystemRoot: process.env.SystemRoot } + : {}, + }, + })).rejects.toThrow(/Missing optional dependency @openai\/codex-[a-z0-9-]+/) + }, 30_000) + + it('runs two named instances concurrently and unloads one without revoking its run', async () => { + const safeInstance = await realInstanceFixture([{ kind: 'hold' }]) + const bypassInstance = await realInstanceFixture([{ + kind: 'complete', + text: 'NAMED_CODEX_BYPASS_RESULT', + }]) + const { ctx, handles, spawnSpecs } = await realRuntime() + const safeFiber = await ctx.plugin(codex, { + providerName: 'codex-safe', + env: safeInstance.env, + permissionMode: 'never', + disposeGraceMs: 2_000, + }) + const bypassFiber = await ctx.plugin(codex, { + providerName: 'codex-bypass', + env: bypassInstance.env, + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + disposeGraceMs: 2_000, + }) + const safeParent = { + id: 'safe-parent', + session: { header: { cwd: safeInstance.workspace } }, + } as unknown as Agent + const bypassParent = { + id: 'bypass-parent', + session: { header: { cwd: bypassInstance.workspace } }, + } as unknown as Agent + const safeController = new AbortController() + + const [safeRun, bypassRun] = await Promise.all([ + ctx.subagents.start('codex-safe', { + prompt: [{ type: 'text', text: 'Hold the safe instance.' }], + parent: safeParent, + signal: safeController.signal, + }), + ctx.subagents.start('codex-bypass', { + prompt: [{ type: 'text', text: 'Complete the bypass instance.' }], + parent: bypassParent, + signal: new AbortController().signal, + }), + ]) + await safeInstance.fixture.requestStarted + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['codex-bypass']) + await expect(ctx.subagents.start('codex-safe', { + prompt: [{ type: 'text', text: 'This start must fail.' }], + parent: safeParent, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'NAMED_CODEX_BYPASS_RESULT' }], + stopReason: 'completed', + }) + safeController.abort(new Error('cancel only the published safe run')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect(safeInstance.fixture.requests).toHaveLength(1) + expect(bypassInstance.fixture.requests).toHaveLength(1) + expect(safeInstance.fixture.requests[0]?.body.input) + .not.toEqual(bypassInstance.fixture.requests[0]?.body.input) + expect(spawnSpecs.map(spec => spec.env?.CODEX_HOME).sort()).toEqual([ + safeInstance.env.CODEX_HOME, + bypassInstance.env.CODEX_HOME, + ].sort()) + await expectQuiescent(handles) + await bypassFiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }, 60_000) + + it('overrides on-request with never and reports a denied command safely', async () => { const command = process.platform === 'win32' ? 'cmd /c type nul > approval-side-effect' : 'touch approval-side-effect' @@ -200,6 +349,11 @@ describe('real @openai/codex 0.147.0 product', () => { kind: 'advertisedFunctionCall', choices: commandCalls, }, + { + kind: 'error', + status: 400, + message: 'fixture terminal failure after permission denial', + }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -207,14 +361,20 @@ describe('real @openai/codex 0.147.0 product', () => { parent: harness.parent, signal: new AbortController().signal, }) - await expect(run.result).resolves.toEqual({ - output: [], - stopReason: 'error', - }) + const result = await run.result + expect(result.output).toEqual([]) + expect(result.stopReason).toBe('error') + expect([ + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ]).toContain(result.diagnostic) + expect(result.diagnostic).not.toContain(command) + expect(result.diagnostic).not.toContain(harness.workspace) await run.dispose() expect(existsSync(sideEffect)).toBe(false) - expect(fixture.requests).toHaveLength(1) + expect(fixture.requests).toHaveLength(2) const tools = fixture.requests[0]!.body.tools as Array> expect(commandCalls.some(call => tools.some(tool => ( tool.type === 'function' && tool.name === call.name @@ -225,6 +385,48 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) + it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => { + const sideEffect = 'bypass-side-effect' + const { harness, fixture } = await realHarness((workspace): readonly ResponsesBehavior[] => { + const target = join(workspace, sideEffect) + const command = process.platform === 'win32' + ? `powershell.exe -NoLogo -NoProfile -NonInteractive -Command "Set-Content -LiteralPath '${target.replaceAll("'", "''")}' -Value 'bypass' -NoNewline"` + : `printf bypass > ${JSON.stringify(target)}` + const commandCalls = [ + { + name: 'exec_command', + arguments: { + cmd: command, + }, + }, + { + name: 'shell_command', + arguments: { + command, + }, + }, + ] as const + return [ + { kind: 'advertisedFunctionCall', choices: commandCalls }, + { kind: 'complete', text: 'bypass complete' }, + ] + }, 'dangerously-bypass-approvals-and-sandbox') + const target = join(harness.workspace, sideEffect) + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Create the fixture side effect.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'bypass complete' }], + stopReason: 'completed', + }) + expect(existsSync(target), JSON.stringify(fixture.requests.at(-1)?.body.input)).toBe(true) + expect(readFileSync(target, 'utf8').trim()).toBe('bypass') + await run.dispose() + await expectQuiescent(harness.handles) + }, 60_000) + it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { const { harness, fixture } = await realHarness([{ kind: 'hold' }]) const controller = new AbortController() diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 2b6e5868ae..c2ef18d803 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -17,6 +17,7 @@ interface RecordedResponsesRequest { /** Behavior consumed by one Responses request. */ export type ResponsesBehavior = | { readonly kind: 'complete'; readonly text: string } + | { readonly kind: 'error'; readonly status: number; readonly message: string } | { readonly kind: 'functionCall' readonly name: string @@ -275,6 +276,11 @@ export async function startResponsesFixture( response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } })) return } + if (behavior.kind === 'error') { + response.writeHead(behavior.status, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: behavior.message } })) + return + } response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 37b2e9ff0b..6ba6f65e87 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,6 +1,10 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as yaml from 'js-yaml' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' @@ -10,11 +14,14 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessOutcome, + SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { + CODEX_PERMISSION_MODES, + DEFAULT_CODEX_PERMISSION_MODE, codexAppServerArgv, DEFAULT_DISPOSE_GRACE_MS, disposeCodexChild, @@ -24,8 +31,50 @@ import { } from '../src/run.ts' import { CodexAppServerWire } from '../src/wire.ts' +const { hostStderrWrite } = vi.hoisted(() => ({ + hostStderrWrite: { + capture: false, + failNext: false, + chunks: [] as Buffer[], + }, +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileSync( + fd: number, + value: string | Uint8Array, + ): void { + if (fd === 2 && hostStderrWrite.capture) { + if (hostStderrWrite.failNext) { + hostStderrWrite.failNext = false + throw Object.assign(new Error('host stderr broke'), { code: 'EIO' }) + } + const bytes = typeof value === 'string' + ? Buffer.from(value) + : Buffer.from(value.buffer, value.byteOffset, value.byteLength) + hostStderrWrite.chunks.push(bytes) + return + } + actual.writeFileSync(fd, value) + }, + } +}) + type JsonObject = Record +const CODEX_VERSION = '0.147.0' +const CODEX_PLATFORM_PACKAGES = [ + '@openai/codex-darwin-arm64', + '@openai/codex-darwin-x64', + '@openai/codex-linux-arm64', + '@openai/codex-linux-x64', + '@openai/codex-win32-arm64', + '@openai/codex-win32-x64', +] as const + const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } }, @@ -101,8 +150,10 @@ interface FakeChild { readonly peer: ProtocolPeer readonly fromChild: PassThrough readonly toChild: PassThrough + readonly stderr: PassThrough readonly settle: (outcome?: SubprocessOutcome) => void readonly fail: (error: Error) => void + readonly setStderr: (text: string) => void readonly terminate: () => void readonly waitForExit: (signal?: AbortSignal) => Promise } @@ -110,6 +161,7 @@ interface FakeChild { function fakeChild(options: FakeChildOptions = {}): FakeChild { const fromChild = new PassThrough() const toChild = new PassThrough() + const stderr = new PassThrough() const peer = new ProtocolPeer(toChild, fromChild) let exited = false let resolveDone!: (outcome: SubprocessOutcome) => void @@ -159,7 +211,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { pid: options.pid ?? 1234, stdin: toChild, stdout: fromChild, - stderr: undefined, + stderr, collected: {}, done, terminate, @@ -170,19 +222,30 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { peer, fromChild, toChild, + stderr, settle, fail, + setStderr: (text: string): void => { stderr.write(text) }, terminate, waitForExit, } } +function defaultWire(child: FakeChild): CodexAppServerWire { + return new CodexAppServerWire( + child.handle.stdout!, + child.handle.stdin!, + DEFAULT_CODEX_PERMISSION_MODE, + ) +} + function runSpec( child: FakeChild, overrides: Partial = {}, ): CodexRunSpec { return { cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, env: {}, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: () => child.handle, @@ -195,7 +258,7 @@ async function initializeWire(): Promise<{ readonly wire: CodexAppServerWire }> { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const initializing = wire.initialize(new AbortController().signal) const initialize = await child.peer.nextMethod('initialize') @@ -260,17 +323,66 @@ function turnCompleted( } describe('task admission and package contracts', () => { - it('resolves the fixed app-server command through the Windows npm shim boundary', () => { - expect(codexAppServerArgv('win32')).toEqual([ - 'cmd.exe', - '/d', - '/s', - '/c', - 'codex', + it('ships one independently installable provider-only Bundle patch', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + files?: string[] + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.files).toContain('cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty( + '@deepseek-ai/dsh-sdk-protocol', + 'workspace:^', + ) + expect(manifest.dependencies).toHaveProperty('@openai/codex', CODEX_VERSION) + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') + + const codexPackageJson = fileURLToPath(import.meta.resolve('@openai/codex/package.json')) + const codexManifest = JSON.parse(readFileSync(codexPackageJson, 'utf8')) as { + version: string + bin: { codex: string } + optionalDependencies: Record + } + expect(codexManifest.version).toBe(CODEX_VERSION) + expect(codexManifest.bin).toEqual({ codex: 'bin/codex.js' }) + expect(codexManifest.optionalDependencies).toEqual(Object.fromEntries( + CODEX_PLATFORM_PACKAGES.map(packageName => [ + packageName, + `npm:@openai/codex@${CODEX_VERSION}-${packageName.slice('@openai/codex-'.length)}`, + ]), + )) + expect(codexAppServerArgv()).toEqual([ + process.execPath, + resolve(dirname(codexPackageJson), codexManifest.bin.codex), 'app-server', '--stdio', ]) - expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio']) + + const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8') + for (const packageName of CODEX_PLATFORM_PACKAGES) { + const suffix = packageName.slice('@openai/codex-'.length) + expect(lockfile).toContain(` '@openai/codex@${CODEX_VERSION}-${suffix}':`) + expect(lockfile).toContain( + ` '${packageName}': '@openai/codex@${CODEX_VERSION}-${suffix}'`, + ) + } + + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) + const rows = Array.isArray(parsed) + ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) + : [] + expect(rows).toEqual([{ + id: 'subagent-codex', + name: '@deepseek-ai/dsh-subagent-codex', + }]) + expect(JSON.stringify(rows)).not.toContain('tool-subagent') + }) + + it('uses only the official package-declared wrapper for app-server', () => { + expect(codexAppServerArgv()[0]).toBe(process.execPath) + expect(codexAppServerArgv().slice(2)).toEqual(['app-server', '--stdio']) }) it('accepts one or more text blocks and rejects empty or non-text tasks', () => { @@ -285,7 +397,7 @@ describe('task admission and package contracts', () => { .toThrow('must not be empty') }) - it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) @@ -314,6 +426,179 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('keeps named instances, runs, and HMR ownership isolated', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const safeChild = fakeChild() + const bypassChild = fakeChild() + const spawnSpecs: SubprocessSpawnSpec[] = [] + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) + return spec.env?.DSH_CODEX_INSTANCE === 'safe' + ? safeChild.handle + : bypassChild.handle + }) + const added: string[] = [] + const started: string[] = [] + const ended: string[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', provider => void added.push(provider.name)) + ctx.on('subagent/start', info => void started.push(info.provider)) + ctx.on('subagent/end', info => void ended.push(info.provider)) + ctx.on('subagent/provider-removed', providerName => void removed.push(providerName)) + const safeFiber = await ctx.plugin(codex, { + providerName: 'codex-safe', + env: { DSH_CODEX_INSTANCE: 'safe' }, + permissionMode: 'never', + disposeGraceMs: 11, + }) + const bypassFiber = await ctx.plugin(codex, { + providerName: 'codex-bypass', + env: { DSH_CODEX_INSTANCE: 'bypass' }, + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + disposeGraceMs: 29, + }) + expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass']) + expect(added).toEqual(['codex-safe', 'codex-bypass']) + + const safeController = new AbortController() + const safeStarting = ctx.subagents.start( + 'codex-safe', + request(undefined, safeController.signal), + ) + const bypassStarting = ctx.subagents.start('codex-bypass', request()) + for (const child of [safeChild, bypassChild]) { + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { + thread: { id: 'thread-1', ephemeral: true }, + }) + } + const [safeRun, bypassRun] = await Promise.all([ + safeStarting, + bypassStarting, + ]) + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['codex-bypass']) + expect(removed).toEqual(['codex-safe']) + await expect(ctx.subagents.start('codex-safe', request())) + .rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + const safeTurn = await safeChild.peer.nextMethod('turn/start') + const bypassTurn = await bypassChild.peer.nextMethod('turn/start') + safeChild.peer.respond(safeTurn, { turn: { id: 'turn-safe' } }) + bypassChild.peer.send( + { id: bypassTurn.id, result: { turn: { id: 'turn-bypass' } } }, + agentMessage('bypass answer', 'final_answer', 'turn-bypass'), + turnCompleted('completed', 'turn-bypass'), + ) + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'bypass answer' }], + stopReason: 'completed', + }) + safeController.abort(new Error('stop only the safe instance')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(spawnSpecs.map(spec => ({ + instance: spec.env?.DSH_CODEX_INSTANCE, + graceMs: spec.graceMs, + }))).toEqual([ + { instance: 'safe', graceMs: 11 }, + { instance: 'bypass', graceMs: 29 }, + ]) + + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect([...started].sort()).toEqual(['codex-bypass', 'codex-safe']) + expect([...ended].sort()).toEqual(['codex-bypass', 'codex-safe']) + expect(safeChild.terminate).toHaveBeenCalledOnce() + expect(bypassChild.terminate).toHaveBeenCalledOnce() + await bypassFiber.dispose() + expect(removed).toEqual(['codex-safe', 'codex-bypass']) + await ctx.fiber.dispose() + }) + + it('rejects duplicate provider names without replacing the first instance', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const firstFiber = await ctx.plugin(codex, { + providerName: 'codex-duplicate', + }) + const first = ctx.subagents.getProvider('codex-duplicate') + await expect(ctx.plugin(codex, { + providerName: 'codex-duplicate', + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' }) + expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first) + expect(ctx.subagents.list()).toEqual(['codex-duplicate']) + await firstFiber.dispose() + await ctx.fiber.dispose() + }) + + it('accepts only the three fixed non-interactive permission modes', () => { + expect(codex.Config({}).providerName).toBe('codex') + expect(codex.Config({ providerName: 'codex-safe' }).providerName) + .toBe('codex-safe') + expect(() => codex.Config({ providerName: '' })).toThrow() + expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE) + for (const permissionMode of CODEX_PERMISSION_MODES) { + expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode) + } + for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) { + expect(() => codex.Config({ permissionMode } as never)).toThrow() + } + }) + + it('resolves the safe permission default when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) + expect(ctx.subagents.getProvider('codex')).toBeDefined() + await ctx.fiber.dispose() + }) + + it.each([ + ['never', { approvalPolicy: 'never' }], + ['approve-for-me', { + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }], + ['dangerously-bypass-approvals-and-sandbox', { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + }], + ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => { + const child = fakeChild() + const wire = new CodexAppServerWire( + child.handle.stdout!, + child.handle.stdin!, + permissionMode, + ) + wire.start() + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) + await initializing + await child.peer.nextMethod('initialized') + const starting = wire.startThread('/workspace', new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: '/workspace', + ephemeral: true, + ...expected, + }) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + wire.close() + }) + it('requires a parent session cwd without suggesting unsupported config', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -363,7 +648,7 @@ describe('task admission and package contracts', () => { describe('CodexAppServerWire', () => { it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) expect(wire.collectOutput()).toEqual([]) wire.start() @@ -386,7 +671,11 @@ describe('CodexAppServerWire', () => { const starting = wire.startThread('/workspace', new AbortController().signal) const threadStart = await child.peer.nextMethod('thread/start') - expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true }) + expect(threadStart.params).toEqual({ + cwd: '/workspace', + ephemeral: true, + approvalPolicy: 'never', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) await starting @@ -473,7 +762,7 @@ describe('CodexAppServerWire', () => { it('rejects invalid handshake, thread, and turn response shapes', async () => { { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) const frame = await child.peer.nextMethod('initialize') @@ -483,7 +772,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.startThread('/workspace', new AbortController().signal) const frame = await child.peer.nextMethod('thread/start') @@ -586,15 +875,31 @@ describe('CodexAppServerWire', () => { threadId: 'thread-1', turnId: 'turn-1', availableDecisions: ['decline', 'cancel'], + command: 'cat /private/secret.txt', }, }) expect(await child.peer.nextResponse('command')).toMatchObject({ result: { decision: 'cancel' }, }) + expect(wire.collectDiagnostic()).toBeUndefined() child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + ) const requests = [ + { + id: 'command-decline', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval', + }, { id: 'file', method: 'item/fileChange/requestApproval', @@ -604,30 +909,46 @@ describe('CodexAppServerWire', () => { availableDecisions: ['decline'], }, result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', + }, + { + id: 'file-cancel', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + result: { decision: 'cancel' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval', }, { id: 'file-default', method: 'item/fileChange/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', }, { id: 'permissions', method: 'item/permissions/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { permissions: {}, scope: 'turn' }, + diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions', }, { id: 'user-input', method: 'item/tool/requestUserInput', params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] }, result: { answers: {} }, + diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers', }, { id: 'mcp', method: 'mcpServer/elicitation/request', params: { threadId: 'thread-1', turnId: null }, result: { action: 'decline', content: null, _meta: null }, + diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', }, ] as const for (const serverRequest of requests) { @@ -635,13 +956,205 @@ describe('CodexAppServerWire', () => { expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({ result: serverRequest.result, }) + expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic) } + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('records only a safe diagnostic for an explicit sandbox failure', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'failed at /private/secret.txt with SECRET_TOKEN', + additionalDetails: 'raw command payload', + codexErrorInfo: 'sandboxError', + })) + await expect(result).rejects.toThrow('status failed') + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', + ) + expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + wire.close() + }) + + it('records declined command and file items without retaining their payloads', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'commandExecution', + status: 'declined', + command: 'cat /private/secret.txt', + }, + }, + }) + await nextTask() + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode', + ) + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + + child.peer.send( + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + status: 'declined', + patch: 'SECRET_TOKEN in /private/secret.txt', + }, + }, + }, + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + }), + ) + await expect(result).rejects.toThrow('status failed') + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode', + ) + expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + wire.close() + }) + + it('recognizes large, split, and ordered stderr signatures without retaining raw text', () => { + const first = fakeChild() + const largeWire = new CodexAppServerWire( + first.handle.stdout!, + first.handle.stdin!, + 'never', + ) + largeWire.observeStderr( + `SECRET_TOKEN approval policy is Never; reject command${'x'.repeat(2_048)}`, + ) + expect(largeWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ) + expect(largeWire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + + const second = fakeChild() + const splitWire = new CodexAppServerWire( + second.handle.stdout!, + second.handle.stdin!, + 'never', + ) + splitWire.observeStderr('SECRET_TOKEN approval policy is Ne') + splitWire.observeStderr('ver; reject command — /private/secret.txt') + expect(splitWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ) + expect(splitWire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(splitWire.collectDiagnostic()).not.toContain('/private/secret.txt') + + const third = fakeChild() + const orderedWire = new CodexAppServerWire( + third.handle.stdout!, + third.handle.stdin!, + 'dangerously-bypass-approvals-and-sandbox', + ) + orderedWire.observeStderr( + 'approval policy is Never; reject command; recorded sandbox violation: path=/private/secret.txt', + ) + expect(orderedWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: sandbox execution; decision: failed): Codex reported a sandbox violation', + ) + expect(orderedWire.collectDiagnostic()).not.toContain('/private/secret.txt') + }) + + it('does not reapply an old stderr signature after a newer request diagnostic', async () => { + const { child, wire } = await initializeWire() + wire.observeStderr('recorded sandbox violation:') + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'file-approval', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + }) + await child.peer.nextResponse('file-approval') + expect(wire.collectDiagnostic()).toContain('request: file approval') + wire.observeStderr('later benign stderr') + expect(wire.collectDiagnostic()).toContain('request: file approval') child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) wire.close() }) + it('keeps a newer request diagnostic after replaying an older early item', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'fileChange', status: 'declined' }, + }, + }) + await nextTask() + child.peer.send({ + id: 'newer-command-request', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + }) + await child.peer.nextResponse('newer-command-request') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(wire.collectDiagnostic()).toContain('request: command approval') + wire.close() + }) + + it('keeps a newer stderr fact after replaying an older early terminal', async () => { + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'sandbox failure', + codexErrorInfo: 'sandboxError', + })) + await nextTask() + wire.observeStderr('approval policy is Never; reject command') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await expect(result).rejects.toThrow('sandboxError') + expect(wire.collectDiagnostic()).toContain('request: command execution') + wire.close() + hostStderrWrite.capture = false + }) + it('fails the run on unknown requests or wrong request association', async () => { for (const serverRequest of [ { @@ -704,6 +1217,44 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('does not retain a diagnostic from a mismatched early item', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-early', + item: { type: 'fileChange', status: 'declined' }, + }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + expect(wire.collectDiagnostic()).toBeUndefined() + wire.close() + }) + + it('does not retain a diagnostic from a mismatched provisional request', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + id: 'provisional-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-early', + availableDecisions: ['cancel'], + }, + }) + await child.peer.nextResponse('provisional-approval') + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + expect(wire.collectDiagnostic()).toBeUndefined() + wire.close() + }) + it('rejects conflicting early notifications and requests before turn/start', async () => { { const { child, wire } = await initializeWire() @@ -790,7 +1341,7 @@ describe('CodexAppServerWire', () => { it('rejects pending work on abort, EOF, and stream error', async () => { { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const controller = new AbortController() controller.abort('pre-aborted') @@ -800,7 +1351,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const controller = new AbortController() const pending = wire.initialize(controller.signal) @@ -811,7 +1362,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -821,7 +1372,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -831,7 +1382,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -864,7 +1415,7 @@ describe('run lifecycle and quiescence', () => { expect(spawn).toHaveBeenCalledWith({ argv: codexAppServerArgv(), cwd: process.cwd(), - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: DEFAULT_DISPOSE_GRACE_MS, env: { OPENAI_API_KEY: 'fake' }, }) @@ -929,6 +1480,114 @@ describe('run lifecycle and quiescence', () => { await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) await run.dispose() } + { + const child = fakeChild() + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.emit('error', new Error('stderr broke')) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose() + expect(child.stderr.listenerCount('error')).toBe(0) + } + }) + + it('attaches a safe permission diagnostic when a published run fails', async () => { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'approval-diagnostic', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + command: 'cat /private/secret.txt', + }, + }) + expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({ + result: { decision: 'cancel' }, + }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) + await run.dispose() + }) + + it('drains queued stderr before settling a failed published run', async () => { + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + setImmediate(() => { + child.stderr.write('approval policy is Never; reject command') + }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + await run.dispose() + hostStderrWrite.capture = false + }) + + it('forwards stderr while extracting only a fixed safe permission signature', async () => { + const child = fakeChild() + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.write('SECRET_TOKEN approval policy is Ne') + child.stderr.write('ver; reject command — /private/secret.txt') + child.stderr.emit('data', 'string stderr suffix') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') + expect(hostStderrWrite.chunks).toHaveLength(3) + await run.dispose() + expect(child.stderr.listenerCount('data')).toBe(0) + hostStderrWrite.capture = false + }) + + it('contains host stderr write failures without changing run settlement', async () => { + const child = fakeChild() + hostStderrWrite.capture = true + hostStderrWrite.failNext = true + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.write('approval policy is Never; reject command') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + await run.dispose() + hostStderrWrite.capture = false }) it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { @@ -939,6 +1598,7 @@ describe('run lifecycle and quiescence', () => { request(undefined, controller.signal), { cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, env: {}, disposeGraceMs: 10, spawn, @@ -952,6 +1612,27 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, null) await expect(starting).rejects.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) + + const stderrChild = fakeChild() + const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) + const stderrInitialize = await stderrChild.peer.nextMethod('initialize') + stderrChild.stderr.emit('error', new Error('startup stderr broke')) + stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.147.0' }) + await stderrChild.peer.nextMethod('initialized') + const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start') + stderrChild.peer.respond(stderrThreadStart, { + thread: { id: 'thread-1', ephemeral: true }, + }) + const stderrRun = await stderrStarting + const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start') + stderrChild.peer.send( + { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } }, + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' }) + await stderrRun.dispose() + expect(stderrChild.stderr.listenerCount('error')).toBe(0) }) it('rolls back an abort that wins immediately after thread creation', async () => { @@ -965,6 +1646,11 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: process.cwd(), + ephemeral: true, + approvalPolicy: 'never', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) controller.abort('startup race') await expect(starting).rejects.toThrow('aborted before run publication') @@ -988,13 +1674,59 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) + it('surfaces only the wrapper missing-payload diagnostic during startup', async () => { + const child = fakeChild() + child.setStderr([ + `credential-like unrelated stderr ${'x'.repeat(16 * 1024)}`, + 'Error: Missing optional dependency @openai/codex-linux-x64. ' + + 'Reinstall Codex: pnpm add -g @openai/codex@latest', + ].join('\n')) + const starting = startCodexRun(request(), runSpec(child)) + child.settle({ exitCode: 1, signal: null }) + + const error: unknown = await starting.then( + () => undefined, + (failure: unknown) => failure, + ) + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) throw new Error('expected startup failure') + expect(error.message).toContain('Missing optional dependency @openai/codex-linux-x64') + expect(error.message).not.toContain('credential-like unrelated stderr') + expect(error.message).not.toContain('Reinstall Codex') + expect(error.message).not.toContain('pnpm add -g') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('waits for process settlement before sampling the missing-payload diagnostic', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const starting = startCodexRun(request(), runSpec(child)) + child.fromChild.end() + await vi.waitFor(() => { expect(child.terminate).toHaveBeenCalledTimes(1) }) + + child.setStderr('Error: Missing optional dependency @openai/codex-linux-x64.') + child.settle({ exitCode: 1, signal: null }) + + await expect(starting).rejects.toThrow( + 'Missing optional dependency @openai/codex-linux-x64', + ) + }) + it('keeps overlapping runs isolated', async () => { - const first = fakeChild() - const second = fakeChild() - const runs = await Promise.all([ - publishRun(first), - publishRun(second), - ]) + const initialStderrListeners = { + error: process.stderr.listenerCount('error'), + unpipe: process.stderr.listenerCount('unpipe'), + close: process.stderr.listenerCount('close'), + finish: process.stderr.listenerCount('finish'), + } + const runs = await Promise.all( + Array.from({ length: 6 }, () => publishRun(fakeChild())), + ) + expect({ + error: process.stderr.listenerCount('error'), + unpipe: process.stderr.listenerCount('unpipe'), + close: process.stderr.listenerCount('close'), + finish: process.stderr.listenerCount('finish'), + }).toEqual(initialStderrListeners) for (const [index, entry] of runs.entries()) { const id = `turn-${index + 1}` entry.child.peer.send( @@ -1004,14 +1736,64 @@ describe('run lifecycle and quiescence', () => { ) } const results = await Promise.all(runs.map(entry => entry.run.result)) - expect(results.map(result => result.output)).toEqual([ - [{ type: 'text', text: 'answer-1' }], - [{ type: 'text', text: 'answer-2' }], - ]) - expect(runs[0].run.id).not.toBe(runs[1].run.id) + expect(results.map(result => result.output)).toEqual( + Array.from({ length: 6 }, (_, index) => [ + { type: 'text', text: `answer-${index + 1}` }, + ]), + ) + expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id) await Promise.all(runs.map(entry => entry.run.dispose())) }) + it('isolates permission modes and diagnostics across overlapping runs', async () => { + const first = await publishRun(fakeChild(), undefined, { + permissionMode: 'never', + }) + const second = await publishRun(fakeChild(), undefined, { + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + }) + first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } }) + second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } }) + await nextTask() + first.child.peer.send({ + id: 'never-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-never', + availableDecisions: ['cancel'], + }, + }) + second.child.peer.send({ + id: 'bypass-elicitation', + method: 'mcpServer/elicitation/request', + params: { threadId: 'thread-1', turnId: null }, + }) + await Promise.all([ + first.child.peer.nextResponse('never-approval'), + second.child.peer.nextResponse('bypass-elicitation'), + ]) + first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', { + message: 'first failure', + codexErrorInfo: 'other', + })) + second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', { + message: 'second failure', + codexErrorInfo: 'other', + })) + await expect(first.run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) + await expect(second.run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + stopReason: 'error', + }) + await Promise.all([first.run.dispose(), second.run.dispose()]) + }) + it('uses the registered provider config and logs flattened errors', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -1023,10 +1805,12 @@ describe('run lifecycle and quiescence', () => { warnings.push(String(message)) }) as typeof ctx.logger.warn await ctx.plugin(codex, { + providerName: 'codex-diagnostic', env: { OPENAI_API_KEY: 'fake' }, + permissionMode: 'approve-for-me', disposeGraceMs: 25, }) - const starting = ctx.subagents.start('codex', { + const starting = ctx.subagents.start('codex-diagnostic', { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent, signal: new AbortController().signal, @@ -1035,20 +1819,50 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: process.cwd(), + ephemeral: true, + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) const run = await starting - await child.peer.nextMethod('turn/start') - child.settle({ exitCode: 1, signal: null }) - await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'provider-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + command: 'cat /private/secret.txt', + }, + }) + await child.peer.nextResponse('provider-approval') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: codexAppServerArgv(), env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), })) expect(warnings).toEqual([ - expect.stringContaining('subagent-codex: child run failed (error):'), + expect.stringContaining('subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: Codex turn ended with status failed: error'), ]) - await run.dispose().catch(() => {}) + expect(warnings.join('\n')).not.toContain('SECRET_TOKEN') + expect(warnings.join('\n')).not.toContain('/private/secret.txt') + await run.dispose() await ctx.fiber.dispose() }) }) @@ -1056,7 +1870,7 @@ describe('run lifecycle and quiescence', () => { describe('disposeCodexChild', () => { it('closes stdin, terminates, and waits for the managed tree', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) const end = vi.spyOn(child.toChild, 'end') await disposeCodexChild(wire, child.handle) expect(end).toHaveBeenCalled() @@ -1067,7 +1881,7 @@ describe('disposeCodexChild', () => { it('does not finish disposal before the managed tree exits', async () => { const child = fakeChild({ exitOnTerminate: false }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) let disposed = false const disposal = disposeCodexChild(wire, child.handle).then(() => { disposed = true @@ -1081,7 +1895,7 @@ describe('disposeCodexChild', () => { it('contains a concurrently closed stdin error', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) vi.spyOn(child.toChild, 'end').mockImplementation(() => { throw new Error('already closed') }) @@ -1094,7 +1908,7 @@ describe('disposeCodexChild', () => { pid: -1, doneError: new Error('spawn failed'), }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() expect(child.terminate).not.toHaveBeenCalled() @@ -1106,14 +1920,14 @@ describe('disposeCodexChild', () => { const child = fakeChild({ doneError: new Error('close observer failed'), }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .rejects.toThrow('close observer failed') } { const child = fakeChild() const handle = { ...child.handle, stdin: undefined } - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index fde3009342..d140507b44 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 25657a6a7dd6727bb5177c828241ec82192f20d8 -README.zh.md: 2f36f950e0a4b0bb86d97633dd0e841fd331ad50 +README.md: 99b28fb7fc62473fa02fef4f3977f29e9935edaa +README.zh.md: 6970c8f21424cb73d170602c52fb98641d5d712d diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 25657a6a7d..99b28fb7fc 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -65,7 +65,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract). +`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 2f36f950e0..6970c8f214 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -65,7 +65,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index d049dba2be..abb6dd50e7 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -16,6 +16,31 @@ import { isAbsolute, resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts' +/** Maximum UTF-8 size of {@link SubagentResult.diagnostic}. */ +const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 + +const DIAGNOSTIC_TRUNCATION_SUFFIX = '\n[diagnostic truncated]' +const utf8Encoder = new TextEncoder() +const utf8Decoder = new TextDecoder() + +/** + * Limit provider-authored failure detail without splitting a UTF-8 sequence. + * @param diagnostic - safe diagnostic text produced by the provider. + * @returns the original text, or a visibly truncated value within the limit. + */ +function limitSubagentDiagnostic(diagnostic: string): string { + const bytes = utf8Encoder.encode(diagnostic) + if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return diagnostic + + const suffixBytes = utf8Encoder.encode(DIAGNOSTIC_TRUNCATION_SUFFIX).byteLength + let prefixBytes = MAX_SUBAGENT_DIAGNOSTIC_BYTES - suffixBytes + while (((bytes[prefixBytes] as number) & 0b1100_0000) === 0b1000_0000) { + prefixBytes -= 1 + } + return utf8Decoder.decode(bytes.subarray(0, prefixBytes)) + + DIAGNOSTIC_TRUNCATION_SUFFIX +} + /** * The capability advertisement of an out-of-process backend: NONE. A child in * another process cannot honor parent-enforced start features @@ -134,6 +159,8 @@ export interface RunResultSettlement { attempt: () => Promise /** Snapshot the provider exposes when cancellation or failure wins settlement. */ collectOutput: () => ContentBlock[] + /** Snapshot safe provider-authored detail when a failure wins settlement. */ + collectDiagnostic?: (() => string | undefined) | undefined /** Whether local cancellation settled before the attempt's outcome is observed. */ cancelled: () => boolean /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */ @@ -168,7 +195,15 @@ export async function settleRunResult(parts: RunResultSettlement): Promise { it.each([ @@ -62,4 +67,51 @@ describe('outcome mapping helpers', () => { detail: 'Error: result failed; dispose failed: Error: reap failed', }) }) + + it('keeps provider diagnostics separate in failed background outcomes', async () => { + await expect(settleRun({ + id: SessionId('child-diagnostic'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'partial assistant text' }], + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', + }), + dispose: () => Promise.resolve(), + })).resolves.toEqual({ + status: 'failed', + detail: 'error; diagnostic: Claude Code denied a tool request', + }) + }) + + it('bounds multibyte diagnostics and marks truncation', async () => { + const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + const controller = new AbortController() + const exactResult = await settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [], + collectDiagnostic: () => exact, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + }) + expect(exactResult.diagnostic).toBe(exact) + + const result = await settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [], + collectDiagnostic: () => oversized, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + }) + const limited = result.diagnostic ?? '' + expect(Buffer.byteLength(limited, 'utf8')) + .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + expect(limited.endsWith('[diagnostic truncated]')).toBe(true) + expect(limited).not.toContain('\uFFFD') + expect(result.stopReason).toBe('error') + expect(result.diagnostic).toBe(limited) + }) }) diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 41f712ba34..b5e6ebf724 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md -README.md: 9d7ed2e364f6a9dff26a1c9006535f898bdaabcc -README.zh.md: 8650ee35588c2615e4d6c016cb672030ee2e8194 +README.md: 28e6213b903ffffa7934e244b2a74ada519b32b2 +README.zh.md: deae0f0ff9e19b627a04704eccf4b8874f34068f diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 9d7ed2e364..28e6213b90 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -8,9 +8,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text. Abort, refusal, token limit, and other failures become errored tool results whose message contains the stop-reason headline, an optional provider-authored `SubagentResult.diagnostic`, and then any preserved partial assistant text. The diagnostic remains separate from `SubagentResult.output`, so a truncated answer is never reported as success or confused with infrastructure detail. If result collection and disposal both reject, the errored result preserves both failures. -`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job `, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md). +`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job `, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices; a failed Task keeps the stop reason and the same optional provider diagnostic in its detail. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -51,7 +51,7 @@ Prefix-stable while provider instances, names, descriptions, and schemas are unc #### What the model sees -The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `. Intermediate child steps stay out of the parent. +The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `, followed by a safe provider diagnostic when present and then any partial assistant text. Intermediate child steps stay out of the parent. #### Token effect @@ -65,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent job ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output. +Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent job ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices; failed status detail includes the provider diagnostic when the result supplied one. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 8650ee3558..deae0f0ff9 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -8,9 +8,9 @@ 每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 -前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子 agent 保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本。中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息依次包含终止原因标题、可选的提供方 `SubagentResult.diagnostic`,以及子 agent 保留下来的部分 assistant 文本。诊断与 `SubagentResult.output` 保持分离,因此被截断的回答不会被报告为成功,也不会与基础设施说明混淆。如果结果收集与 dispose(资源释放)都 reject,出错结果会保留两项失败。 -`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 +`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知;失败 Task 的 detail 会保留终止原因与同一份可选提供方诊断。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -51,7 +51,7 @@ #### 模型看到的内容 -调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果变为 `Error: `。子 agent 中间步骤不会进入父级。 +调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果会变为 `Error: <终止原因>`,随后在存在时附上安全的提供方诊断,再附上任何部分 assistant 文本。子 agent 中间步骤不会进入父级。 #### Token 影响 @@ -65,7 +65,7 @@ #### 模型看到的内容 -在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 +在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知;若结果携带提供方诊断,失败状态的 detail 会包含它。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 711ae5a7f4..86d00c6d0b 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -142,18 +142,25 @@ function stopReasonError(result: SubagentResult): string | undefined { } /** - * Append the child's preserved partial answer to a stop-reason error so a - * truncated or cancelled child's real text still reaches the parent model. + * Append provider-authored failure detail and the child's preserved partial + * answer to a stop-reason error, keeping diagnostic text separate from the + * child's assistant output. * @param error - the stop-reason headline. - * @param output - the child's selected output (`SubagentResult.output`). - * @returns the headline, extended with the partial text when any exists. + * @param result - the child's terminal result. + * @returns the headline, diagnostic, and partial text that are present. */ -function withPartialText(error: string, output: ContentBlock[]): string { - const text = output +function withDiagnosticAndPartialText(error: string, result: SubagentResult): string { + const diagnostic = result.diagnostic === undefined + ? '' + : `\nDiagnostic: ${result.diagnostic}` + const text = result.output .filter((block): block is Extract => block.type === 'text') .map(block => block.text) .join('') - return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}` + const partial = text.length === 0 + ? '' + : `\nPartial output before the run ended:\n${text}` + return `${error}${diagnostic}${partial}` } type ForegroundToolResult = { @@ -173,7 +180,7 @@ async function settleForegroundRun(run: SubagentRun): Promise /** Whether tool descriptions say the child inherits completed turns. */ @@ -65,11 +67,17 @@ class ScriptedSubagentProvider implements SubagentProvider { throw new Error('scripted subagent start aborted before publication') } - const resultFor = (): SubagentResult => ({ - output, - ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, - stopReason: state.cancelled ? 'aborted' : stopReason, - }) + const resultFor = (): SubagentResult => { + const terminal = state.cancelled ? 'aborted' : stopReason + return { + output, + ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, + ...this.config.diagnostic !== undefined && terminal !== 'completed' + ? { diagnostic: this.config.diagnostic } + : {}, + stopReason: terminal, + } + } const gate = Promise.resolve(this.config.onStart?.(request)) const result = gate.then(() => new Promise((resolve) => { setTimeout(() => { resolve(resultFor()) }, 0) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 44c0cdecb5..1ee5e40228 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -185,6 +185,22 @@ describe('dsh-tool-subagent', () => { expect(text(result)).toContain('scripted subagent reply') }) + it('renders provider diagnostics before preserved partial assistant output', async () => { + const ctx = await setup({ provider: 'mock' }, { + reply: 'partial assistant text', + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', + }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toBe( + 'Error: subagent run failed\n' + + 'Diagnostic: Claude Code denied a tool request\n' + + 'Partial output before the run ended:\npartial assistant text', + ) + }) + it('registers under a configurable toolName so multiple providers can coexist', async () => { // The defining multi-provider use case: two loads, two distinct tool names, // each bound to a different provider — the tool registry rejects duplicate @@ -852,6 +868,36 @@ describe('dsh-tool-subagent background mode', () => { expect(text(again)).toBe('background answer\n[status: completed]') }) + it('preserves provider diagnostics in one-shot background failure detail', async () => { + const ctx = await backgroundSetup({ provider: 'mock' }, { + reply: 'not background output', + diagnostic: 'Claude Code cancelled an unattended dialog', + stopReason: 'error', + }) + const parent = ownerAgent(ctx, 'sess-parent') + + const started = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('diagnostic-background-start'), + name: 'subagent', + arguments: { description: 'd', prompt: 'p', run_in_background: true }, + agent: parent, + }) + expect(text(started)).toBe('started background subagent job subagent-1') + + const output = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('diagnostic-background-output'), + name: 'job_output', + arguments: { job_id: 'subagent-1', wait: true }, + agent: parent, + }) + expect(text(output)).toBe( + '(no new output)\n' + + '[status: failed, error; diagnostic: Claude Code cancelled an unattended dialog]', + ) + }) + it('fails loud when the tasks runtime is not loaded', async () => { const ctx = await setup({ provider: 'mock' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index e59289be09..721c5134c3 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -13,7 +13,6 @@ if ((kind !== 'ordinary' && kind !== 'terminal') } const treeState = join(root, 'tree.json') -const ready = join(root, 'ready') const proceed = join(root, 'proceed') const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) @@ -58,7 +57,6 @@ const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unkn if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { throw new Error('managed tree published invalid process ids') } -await writeFile(ready, 'ready') await waitForFile(proceed) if (trigger === 'dispose') { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index 217338fa1e..cfea99f12a 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -106,11 +106,9 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { let settled = false let treeGone = false try { + // The host validates tree.json before waiting for proceed, so observing it + // is sufficient readiness; a second marker only adds a redundant Windows poll. state = await readTree(join(root, 'tree.json')) - await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { - interval: 10, - timeout: scenarioTimeoutMs, - }) if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) await writeFile(join(root, 'proceed'), 'proceed') const outcome = await child diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index 837f5d9af0..27356a8132 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -644,7 +644,7 @@ describe('installLlmReplay (through the real LlmRuntime)', () => { }) expect(ctx.llm.providerRetryPolicy('empty')).toMatchObject({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, initialDelayMs: 500, maxDelayMs: 10_000, jitterRatio: 0.1, diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index d293beb656..c54e974be8 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/atomic-write/README.md -README.md: a767f24064c368b60d85fed6fa1d88349cab9587 -README.zh.md: 6388e264898e0025fb6586acecab450be3eb9e55 +README.md: 4d0b55291955c9d37f4788c7d37ad8e6ce728f70 +README.zh.md: c2d7f0b49fa123befbb663ac43862a40b4ef19b4 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index a767f24064..4d0b552919 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { - **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. - Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. -`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer. +`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms that the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer. ## Model Experience diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index 6388e26489..c2d7f0b49f 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { - **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 - 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 -`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 +`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 ## 模型体验 diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index 70af9fa40b..21c9de5f35 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -11,7 +11,7 @@ */ import { randomBytes } from 'node:crypto' -import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' /** @@ -63,9 +63,18 @@ export async function writeFileAtomic(filename: string, content: string, options } } -/** Whether an exclusive create failed because the path already exists. */ -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +/** Whether an exclusive create found an existing lock. */ +async function isLockContention(error: unknown, lockPath: string): Promise { + const code = (error as NodeJS.ErrnoException | null)?.code + if (code === 'EEXIST') return true + if (code !== 'EPERM') return false + try { + await lstat(lockPath) + return true + } catch { + // Keep the original EPERM authoritative when lock existence is unproven. + return false + } } /** @@ -82,10 +91,13 @@ const LOCK_TIMEOUT_MS = 2_000 * Hold the cross-process writer lock for `filename` around one operation. The * lock is a `wx`-created sibling (`.lock`); paired with the * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and - * only writers contend. Contention backs off exponentially and fails with a - * timed-out error after the deadline. The contender never removes an existing - * lock because file age cannot prove that its owner stopped; orphan recovery - * is an operator action. The parent directory must exist. + * only writers contend. `EEXIST` is contention directly; an `EPERM` is + * contention only when a fresh `lstat` confirms the lock path exists, covering + * Windows exclusive-create behavior without hiding an unrelated permission + * failure. Contention backs off exponentially and fails with a timed-out error + * after the deadline. The contender never removes an existing lock because + * file age cannot prove that its owner stopped; orphan recovery is an operator + * action. The parent directory must exist. * @param filename - the file whose writers this lock serializes. * @param operation - the read-render-commit cycle to run while holding the lock. * @returns the operation's result; the lock releases on both outcomes. @@ -102,7 +114,7 @@ export async function withFileLock( await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) break } catch (error) { - if (!isEEXIST(error)) throw error + if (!await isLockContention(error, lockPath)) throw error } if (Date.now() >= deadline) { throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`) diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index e71e5b7abd..42cbd287c0 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -1,9 +1,29 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { withFileLock, writeFileAtomic } from '../src/index.ts' +const state = vi.hoisted(() => ({ failLockCreateWithEPERM: false })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFile: (async (path: unknown, ...rest: never[]) => { + if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) { + state.failLockCreateWithEPERM = false + throw Object.assign(new Error('EPERM: injected exclusive-create failure'), { code: 'EPERM' }) + } + return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.writeFile, + } +}) + +afterEach(() => { + state.failLockCreateWithEPERM = false +}) + async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) } @@ -48,6 +68,32 @@ describe('writeFileAtomic', () => { }) describe('withFileLock', () => { + it('retries EPERM only when the lock path currently exists', async () => { + const dir = await scratch() + const target = join(dir, 'document') + const lockPath = `${target}.lock` + await writeFile(lockPath, 'holder\n') + const release = setTimeout(() => { void rm(lockPath, { force: true }) }, 50) + state.failLockCreateWithEPERM = true + let called = false + + try { + await withFileLock(target, async () => { called = true }) + } finally { + clearTimeout(release) + } + expect(called).toBe(true) + }) + + it('preserves EPERM when no lock path exists', async () => { + const dir = await scratch() + const operation = vi.fn(async () => {}) + state.failLockCreateWithEPERM = true + + await expect(withFileLock(join(dir, 'document'), operation)).rejects.toMatchObject({ code: 'EPERM' }) + expect(operation).not.toHaveBeenCalled() + }) + it('rejects an invalid parent hierarchy before running the operation', async () => { const dir = await scratch() const parent = join(dir, 'not-a-directory') diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index 2e50c2ecb2..bfba84d22d 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/tool-web/README.md -README.md: 81dc4d3f3dbf9788e68b86513bfa09271fb99c84 -README.zh.md: 8e976f23f72347e12c6aea35f9b99c5d8ad31d85 +README.md: 787b70a5070f48a3bac6435d5d7e8b64c01e0341 +README.zh.md: bce95dbc0eea1bebe59b0b80cafae2a4eb948aa6 diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 81dc4d3f3d..787b70a507 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam. +The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) (a `tools/execute` wrapper). Single operations forward `exec.signal`; a multi-query search fuses it with batch cancellation so a failed query aborts its siblings. Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). Search guidance mentions `web_fetch` only when fetch is also config-enabled; a search-only composition instead tells the model to use returned snippets and cite their URLs. @@ -10,7 +10,7 @@ Each tool is registered independently; a product that wants only one disables th | Tool | Args | Behavior | |---|---|---| -| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. | +| `web_search` | `queries` (required string[]) | Discovery. Returns an optional answer plus source URLs. It runs one to `searchMaxQueries` distinct searches concurrently and merges their sources in round-robin order before applying the combined `searchMaxResults` cap. A one-item array performs one search. Exact duplicate queries run once. Any failed search aborts the remaining batch, which settles before the call returns an error. Neither bound is model-facing. | | `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown (turndown with GFM tables/strikethrough); text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-tool-call-timeout-policy`), not a model argument. | Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state. @@ -23,12 +23,13 @@ The normalized service results are also the canonical tool values: `WebSearchRes |---|---|---| | `search` | `true` | Register `web_search`. | | `fetch` | `true` | Register `web_fetch`. | -| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). | +| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates each provider list; the tool also caps a combined multi-query list). | +| `searchMaxQueries` | `4` | Upper bound on queries accepted by one `web_search` call. The configured value appears in its prompt guidance and schema descriptions. | | `fetchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_fetch`. | | `searchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_search`. | | `fetchMaxOutputChars` | `200000` | Cap on source characters converted synchronously and on one complete `web_fetch` output (header, rendered body, and footer); a cut body gets the truncation notice when it fits. | -`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md); the model-facing schema exposes no timeout argument. `fetchMaxOutputChars` bounds both synchronous conversion work and the complete rendered result: only that many source characters are converted, and the header, converted prefix, and truncation notice are then capped together. The default leaves headroom above the local provider's 100,000-character body cap, but rendered expansion can still make the final bound truncate the result. +`searchMaxQueries` bounds the accepted array before exact-string deduplication, provider fan-out, and combined provider-answer growth; validation rejects an oversized array before any search starts, then dispatch keeps the first occurrence of each query. Together with each provider's own controls such as `maxUses`, these independent settings are the product's search budgets; the generic seam does not expose provider-internal native-search accounting. `fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md); the model-facing schema exposes no timeout argument. `fetchMaxOutputChars` bounds both synchronous conversion work and the complete rendered result: only that many source characters are converted, and the header, converted prefix, and truncation notice are then capped together. The default leaves headroom above the local provider's 100,000-character body cap, but rendered expansion can still make the final bound truncate the result. ```yaml - id: tool-web @@ -52,13 +53,13 @@ Search and fetch contribute the web-search and web-fetch guidance below. Search ##### Web search guidance with fetch enabled ```markdown -Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links. +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links. ``` ##### Web search-only guidance ```markdown -Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. ``` ##### Web fetch guidance @@ -69,11 +70,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### Token effect -Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch changes the search guidance as well as registering or removing the fetch section. +Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch or changing `searchMaxQueries` changes the search guidance; toggling fetch also registers or removes the fetch section. #### KV Cache effect -Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it. +Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—changing `searchMaxQueries`, or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it. ### Tool schemas @@ -83,26 +84,40 @@ The model sees the generated [`web_search` and `web_fetch` schemas](../../../doc #### Token effect -Fixed schema cost per request; config disablement removes both schema and guidance, while a scoped restriction removes only the schema. +Fixed schema cost per request for a resolved `searchMaxQueries`; config disablement removes both schema and guidance, while a scoped restriction removes only the schema. #### KV Cache effect -Prefix-stable while definitions and visibility are unchanged. Config enablement, plugin lifecycle, or scoped restrictions may invalidate reuse from the first changed schema token. +Prefix-stable while definitions, resolved query cap, and visibility are unchanged. Config enablement, changing `searchMaxQueries`, plugin lifecycle, or scoped restrictions may invalidate reuse from the first changed schema token. ### Search result #### What the model sees -The optional provider-owned answer is followed by `Sources:` and data-dependent lines shaped exactly `- []()`, optionally suffixed ` — ()`. With neither answer nor sources the result says `No results found.` A capped list adds `(Showing the first sources. Refine the query for more.)`; every result ends `Cite the relevant URLs above as markdown links in your answer.` +The optional provider-owned answer is followed by `Sources:` and data-dependent lines shaped exactly `- []()`, optionally suffixed ` — ()`. A multi-query call runs each exact query string once, preserving its first position; it labels each provider answer with the originating query as a markdown heading, deduplicates sources by URL, and takes one source at each rank from every query before advancing to the next rank. With neither answer nor sources the result says `No results found.` A capped list adds `(Showing the first sources. Refine the query for more.)`; every result ends `Cite the relevant URLs above as markdown links in your answer.` #### Token effect -Data-dependent results are resent until compaction and sources are capped by `searchMaxResults`. +Data-dependent results are resent until compaction; query fan-out is capped by `searchMaxQueries`, and sources are capped by `searchMaxResults`. #### KV Cache effect Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Search failure + +#### What the model sees + +If any query in a multi-query call fails, `web_search` aborts the other searches, waits for every started search to settle, discards successful results, and returns `Error: ` for the first failure. + +#### Token effect + +Only the retained error result adds tokens; discarded successful results do not enter model history. + +#### KV Cache effect + +Append-only; the error follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Fetch result #### What the model sees @@ -121,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Blank inputs become exactly `Error: query must be a non-empty string` or `Error: url must be a non-empty string`. +Schema validation rejects an absent or non-array `queries` field and non-string array elements before execution. Value errors become exactly `Error: queries must contain at least one query`, `Error: queries must contain at most 1 query` when the configured cap is one, `Error: queries must contain at most queries` for larger caps, `Error: each query must be a non-empty string`, or `Error: url must be a non-empty string`. #### Token effect @@ -133,6 +148,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work +- **There is no batch-wide native-search counter** — `searchMaxQueries` bounds `ctx.web.search` calls, but a provider may perform several native searches inside each call. For example, a model-backed provider configured with `maxUses` can permit up to `searchMaxQueries × maxUses` native searches; `searchMaxResults` limits only the combined sources returned to the caller. Deployments control cost through these independent consumer and provider settings because the generic seam does not know provider-internal search units. - **HTML→markdown conversion degrades on inputs GFM cannot safely represent** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts at most `fetchMaxOutputChars` source characters through a real DOM. A conservative 512-level lexical guard passes deeply or ambiguously nested bodies through as raw HTML, conversion exceptions do the same, and table `colspan` is ignored because GFM has no spanning-cell representation; these bounds avoid blocking the event loop or expanding output from an untrusted numeric attribute ([archived dependency decision](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). - **The model-facing API is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md). - **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants. diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index 8e976f23f7..bce95dbc0e 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。 +面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md)(`tools/execute` 包装层)强制执行。单项操作会转发 `exec.signal`;多查询搜索会把它与批次取消信号融合,使失败查询能够中止其余查询。 每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。 @@ -10,7 +10,7 @@ | 工具 | 参数 | 行为 | |---|---|---| -| `web_search` | `query`(string) | 用于发现信息。返回可选答案与来源 URL。`max_results` **不**面向模型:工具设置上限(`searchMaxResults` 配置,默认 8)并传给 seam。 | +| `web_search` | `queries`(必填 string[]) | 用于发现信息。返回可选答案与来源 URL。它会并发执行 1 至 `searchMaxQueries` 个不同搜索,按轮询顺序合并来源,再应用组合后的 `searchMaxResults` 上限。单元素数组执行一次搜索。完全相同的查询只执行一次。任何搜索失败都会中止批次中的其余搜索;批次结算完毕后调用才返回错误。两个上限都不面向模型。 | | `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为 markdown(turndown,带 GFM 表格/删除线);文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-tool-call-timeout-policy`),不是模型参数。 | 两个工具都选择并发调度,因为提供方读取会返回内容,不会修改父 agent(智能体)的状态。 @@ -23,12 +23,13 @@ |---|---|---| | `search` | `true` | 注册 `web_search`。 | | `fetch` | `true` | 注册 `web_fetch`。 | -| `searchMaxResults` | `8` | 一次 `web_search` 调用返回的来源数量上限(seam 截断更长的提供方列表并标记)。 | +| `searchMaxResults` | `8` | 一次 `web_search` 调用返回的来源数量上限(seam 截断各提供方列表;工具还会限制多查询组合列表)。 | +| `searchMaxQueries` | `4` | 一次 `web_search` 调用接受的查询数量上限。配置值会出现在提示词指引与 schema 描述中。 | | `fetchTimeoutMs` | `30000` | `web_fetch` 的协作式工具调用超时预算(ms)。 | | `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 | | `fetchMaxOutputChars` | `200000` | 同步转换的源字符数与单次完整 `web_fetch` 输出的上限(状态头、渲染后的主体与页脚合并计算);主体被截断时,在能容纳的情况下附带截断提示。 | -`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。 +`searchMaxQueries` 在完全相同的字符串去重前限制可接受数组、提供方请求扇出与组合后的提供方答案增长;校验会在任何搜索开始前拒绝超限数组,随后分发只保留每个查询第一次出现的位置。该设置与各提供方自己的 `maxUses` 等控制项共同构成产品的搜索预算;通用 seam 不公开提供方内部的原生搜索计数。`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。 ```yaml - id: tool-web @@ -52,13 +53,13 @@ ##### 启用抓取时的 Web 搜索指引 ```markdown -Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links. +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links. ``` ##### 仅搜索时的 Web 搜索指引 ```markdown -Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. ``` ##### Web 抓取指引 @@ -69,11 +70,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### Token 影响 -每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态不仅会注册或移除抓取区段,也会更改搜索指引。 +每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态或更改 `searchMaxQueries` 会改变搜索指引;切换抓取状态还会注册或移除抓取区段。 #### KV Cache 影响 -只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。 +只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)、更改 `searchMaxQueries` 或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。 ### 工具 schema @@ -83,26 +84,40 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### Token 影响 -每次请求都会产生固定的 schema token 开销;通过配置禁用会同时移除 schema 与指引,scope 限制只移除 schema。 +对于已解析的 `searchMaxQueries`,每次请求都会产生固定的 schema token 开销;通过配置禁用会同时移除 schema 与指引,scope 限制只移除 schema。 #### KV Cache 影响 -只要定义与可见性不变,前缀就保持稳定。配置启用状态、插件生命周期或 scope 限制可能使从第一个变化的 schema token 起的复用失效。 +只要定义、已解析查询上限与可见性不变,前缀就保持稳定。配置启用状态、更改 `searchMaxQueries`、插件生命周期或 scope 限制可能使从第一个变化的 schema token 起的复用失效。 ### 搜索结果 #### 模型看到的内容 -可选的提供方答案之后是 `Sources:`,再跟随内容取决于数据且格式严格为 `- []()` 的行,并可添加后缀 ` — ()`。既无答案也无来源时,结果显示 `No results found.`。列表被截断至上限时会添加 `(Showing the first sources. Refine the query for more.)`;每个结果都以 `Cite the relevant URLs above as markdown links in your answer.` 结尾。 +可选的提供方答案之后是 `Sources:`,再跟随内容取决于数据且格式严格为 `- []()` 的行,并可添加后缀 ` — ()`。多查询调用会让每个完全相同的查询字符串只执行一次,并保留它首次出现的位置;调用会用来源查询作为 markdown 标题标注每个提供方答案,按 URL 对来源去重,并从每个查询取得同一排名的一条来源后再推进至下一排名。既无答案也无来源时,结果显示 `No results found.`。列表被截断至上限时会添加 `(Showing the first sources. Refine the query for more.)`;每个结果都以 `Cite the relevant URLs above as markdown links in your answer.` 结尾。 #### Token 影响 -数据相关结果会重复发送直到压缩(compaction),来源数量由 `searchMaxResults` 限制。 +数据相关结果会重复发送直到压缩(compaction);查询请求扇出由 `searchMaxQueries` 限制,来源数量由 `searchMaxResults` 限制。 #### KV Cache 影响 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 搜索失败 + +#### 模型看到的内容 + +多查询调用中的任何查询失败时,`web_search` 会中止其余搜索,等待所有已启动搜索结算,丢弃成功结果,并针对首次失败返回 `Error: `。 + +#### Token 影响 + +只有保留的错误结果会增加 token;被丢弃的成功结果不会进入模型历史。 + +#### KV Cache 影响 + +仅追加;错误位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 + ### 抓取结果 #### 模型看到的内容 @@ -121,7 +136,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### 模型看到的内容 -空输入精确地变为 `Error: query must be a non-empty string` 或 `Error: url must be a non-empty string`。 +schema 校验会在执行前拒绝缺失或非数组的 `queries` 字段以及非字符串数组元素。值错误精确地变为 `Error: queries must contain at least one query`、配置上限为 1 时的 `Error: queries must contain at most 1 query`、上限更大时的 `Error: queries must contain at most queries`、`Error: each query must be a non-empty string` 或 `Error: url must be a non-empty string`。 #### Token 影响 @@ -133,6 +148,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex ## 已知限制与暂缓事项 +- **没有覆盖整个批次的原生搜索计数器**:`searchMaxQueries` 限制 `ctx.web.search` 调用数,但提供方可以在每次调用内执行多次原生搜索。例如,配置了 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索;`searchMaxResults` 只限制返回给调用方的组合来源。部署通过这些独立的消费方与提供方设置控制成本,因为通用 seam 不知道提供方内部的搜索计量单位。 - **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([已归档的依赖决策](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 - **面向模型的接口有意保持精简,后续扩展暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM(大语言模型)摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久化的 URL/域名授权。 diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index a0dcfaa09d..4145fdeb0f 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -9,10 +9,10 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' -import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' +import { applyWebSearchTool, WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' -export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts' +export { WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts' export type { WebSearchMeta } from './search.ts' export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts' export type { WebFetchMeta } from './fetch.ts' @@ -33,7 +33,7 @@ export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000 */ export const DEFAULT_FETCH_MAX_OUTPUT_CHARS = 200_000 -/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */ +/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */ export interface Config { /** Register `web_search`. Defaults to true. */ search?: boolean @@ -41,6 +41,8 @@ export interface Config { fetch?: boolean /** Upper bound on sources returned by one `web_search` call. */ searchMaxResults?: number + /** Upper bound on queries accepted by one `web_search` call. */ + searchMaxQueries?: number /** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */ fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ @@ -53,6 +55,7 @@ export const Config: z = z.object({ search: z.boolean().default(true), fetch: z.boolean().default(true), searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS), + searchMaxQueries: z.number().default(WEB_SEARCH_MAX_QUERIES), fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS), searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS), fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS), @@ -81,11 +84,12 @@ export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig assertPositiveInteger('searchMaxResults', resolved.searchMaxResults) + assertPositiveInteger('searchMaxQueries', resolved.searchMaxQueries) assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs) assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs) assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars) if (resolved.search) { - applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch) + applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchMaxQueries, resolved.searchTimeoutMs, resolved.fetch) } if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars) } diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index d3c4acc756..c5acac7515 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -19,16 +19,36 @@ import type {} from '@deepseek-ai/dsh-system-prompt' */ export const WEB_SEARCH_MAX_RESULTS = 8 +/** Default upper bound on concurrent searches in one tool call. */ +export const WEB_SEARCH_MAX_QUERIES = 4 + +/** Model-facing `web_search` arguments. */ +interface WebSearchArgs { + queries: string[] +} + /** - * Validate value constraints the schema DSL can't express: a non-blank - * `query`. Throws a plain `Error` otherwise. + * Validate value constraints the schema DSL can't express: `queries` is + * non-empty, contains only non-blank strings, and fits the deployment's + * query-count bound. Exact duplicate strings are collapsed after the bound + * check. Throws a plain `Error` otherwise. * * @param args - the schema-validated `web_search` arguments. - * @returns the accepted arguments, passed through unchanged. + * @param maxQueries - the deployment's upper bound on queries in one call. + * @returns the accepted queries in their first-occurrence order. */ -export function parseSearchArgs(args: { query: string }): { query: string } { - if (args.query.trim().length === 0) throw new Error('query must be a non-empty string') - return { query: args.query } +export function parseSearchArgs( + args: WebSearchArgs, + maxQueries: number, +): string[] { + const queries = args.queries + if (queries.length === 0) throw new Error('queries must contain at least one query') + if (queries.length > maxQueries) { + const noun = maxQueries === 1 ? 'query' : 'queries' + throw new Error(`queries must contain at most ${maxQueries} ${noun}`) + } + if (queries.some(query => query.trim().length === 0)) throw new Error('each query must be a non-empty string') + return [...new Set(queries)] } /** Display label for a source: its title, else its hostname. */ @@ -75,13 +95,14 @@ export function formatSearchOutput(result: WebSearchResult): string { } /** - * Pending-call presentation: a search card titled by the query. + * Pending-call presentation: a search card titled by the query list. * - * @param args - the raw tool arguments; only `query` feeds the view. + * @param args - the raw tool arguments; only the query text feeds the view. * @returns the generic card view (`kind: 'search'`) shown while the call runs. */ -export function presentSearchCall(args: { query: string }): GenericCallView { - return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query } +export function presentSearchCall(args: WebSearchArgs): GenericCallView { + const title = args.queries.join(', ') + return { card: 'generic', title, kind: 'search', rawInput: title } } /** @@ -95,7 +116,7 @@ export function presentSearchCall(args: { query: string }): GenericCallView { export interface WebSearchMeta { /** The faithful structured sources, in result order. */ sources: WebSource[] - /** True when the seam cut the source list to honor the result cap. */ + /** True when the seam or multi-query merge cut the source list to honor the result cap. */ truncated: boolean /** The provider-generated answer text, when any. */ answer?: string @@ -175,26 +196,103 @@ export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined { * `web` capability falls back to the raw `tool/result` content, which is the * same text (see the web-result-card Agent Note). * - * @param args - the raw tool arguments; `query` becomes the result-state title so - * a window-truncated replay that dropped the call head still has one. + * @param args - the raw tool arguments; the queries become the result-state + * title so a window-truncated replay that dropped the call head still has one. * @param result - the final model-facing tool result; `meta` carries the sources. * @returns the search result view, or `undefined` (generic card) on failure or * malformed meta. */ -export function presentSearchResult(args: { query: string }, result: ToolResult): WebSearchResultView | undefined { +export function presentSearchResult(args: WebSearchArgs, result: ToolResult): WebSearchResultView | undefined { if (result.isError) return undefined const meta = searchMetaFromResult(result.meta) if (meta === undefined) return undefined return { card: 'web', kind: 'search', - title: args.query, + title: args.queries.join(', '), sources: meta.sources, truncated: meta.truncated, ...meta.answer !== undefined ? { answer: meta.answer } : {}, } } +/** + * Run one or more searches through the web seam. A single query keeps the + * provider's exact result; multiple queries run concurrently and are merged + * into one normalized result capped at `maxResults`. A failed search aborts + * its siblings, and this function waits for every search to settle before + * rethrowing the first failure. + * + * @param ctx - context whose `web` service performs the searches. + * @param queries - validated non-empty queries. + * @param maxResults - the deployment's source cap for the combined result. + * @param signal - cancellation signal forwarded to every search. + * @returns the combined search result. + */ +async function runSearchQueries( + ctx: Context, + queries: string[], + maxResults: number, + signal: AbortSignal, +): Promise { + if (queries.length === 1) { + return ctx.web.search({ query: queries[0] as string, maxResults }, signal) + } + const controller = new AbortController() + const batchSignal = AbortSignal.any([signal, controller.signal]) + let firstFailure: { error: unknown } | undefined + const results: WebSearchResult[] = [] + const searches = queries.map(async (query, index) => { + try { + results[index] = await ctx.web.search({ query, maxResults }, batchSignal) + } catch (error) { + if (firstFailure === undefined) firstFailure = { error } + controller.abort(error) + throw error + } + }) + await Promise.allSettled(searches) + if (firstFailure !== undefined) throw firstFailure.error + return mergeSearchResults(queries, results, maxResults) +} + +/** Merge per-query results into one deduplicated, round-robin, capped result. */ +function mergeSearchResults( + queries: string[], + results: WebSearchResult[], + maxResults: number, +): WebSearchResult { + const seen = new Set() + const sources: WebSearchSource[] = [] + let sourceRanks = 0 + for (const result of results) { + sourceRanks = Math.max(sourceRanks, result.sources.length) + } + let droppedSource = false + merge: for (let rank = 0; rank < sourceRanks; rank++) { + for (const result of results) { + const source = result.sources[rank] + if (source !== undefined && !seen.has(source.url)) { + seen.add(source.url) + if (sources.length === maxResults) { + droppedSource = true + break merge + } + sources.push(source) + } + } + } + const contents = results.flatMap((result, index) => { + if (result.content === undefined || result.content.length === 0) return [] + return [`### ${queries[index]}\n\n${result.content}`] + }) + return { + ...contents.length > 0 ? { content: contents.join('\n\n') } : {}, + sources, + truncated: results.some(result => result.truncated) || droppedSource, + } +} + /** * Register the `web_search` tool and its system-prompt guidance. * @@ -202,6 +300,7 @@ export function presentSearchResult(args: { query: string }, result: ToolResult) * registrations; both are effect-scoped and unregister on plugin dispose. * @param maxResults - the deployment's source cap, sent as every seam * request's `maxResults`. + * @param maxQueries - the deployment's query cap enforced before provider calls. * @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's * `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-tool-call-timeout-policy` to enforce. * @param fetchEnabled - whether the same composition exposes `web_fetch`, which @@ -210,6 +309,7 @@ export function presentSearchResult(args: { query: string }, result: ToolResult) export function applyWebSearchTool( ctx: Context, maxResults: number, + maxQueries: number, timeoutMs: number, fetchEnabled: boolean, ): void { @@ -217,15 +317,20 @@ export function applyWebSearchTool( name: 'tool:web_search', order: 110, text: fetchEnabled - ? 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.' - : 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.', + ? `Use the web_search tool to discover current information on the web. The required queries array accepts 1–${maxQueries} non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.` + : `Use the web_search tool to discover current information on the web. The required queries array accepts 1–${maxQueries} non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.`, }) ctx.tools.register(defineTool({ name: 'web_search', - description: 'Search the web for current information. Returns an optional summary answer and a list of source URLs.', + description: `Search the web for current information. Provide 1–${maxQueries} queries in the required queries array. Returns an optional summary answer and a list of source URLs.`, parameters: { - query: { type: 'string', required: true, description: 'The search query.' }, + queries: { + type: 'array', + required: true, + items: { type: 'string' }, + description: `Required search queries; accepts 1–${maxQueries} items and merges their results.`, + }, }, output: { schema: { @@ -257,11 +362,8 @@ export function applyWebSearchTool( // Provider reads do not mutate parent-agent state. isConcurrencySafe: () => true, async execute(args, exec) { - const input = parseSearchArgs(args) - const result = await ctx.web.search( - { query: input.query, maxResults }, - exec.signal, - ) + const queries = parseSearchArgs(args, maxQueries) + const result = await runSearchQueries(ctx, queries, maxResults, exec.signal) return { ...result.content !== undefined ? { content: result.content } : {}, sources: result.sources.map(projectSource), diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 1ee7cb359c..1aa1fa6416 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -97,7 +97,7 @@ describe('web_search integration over the real Exa provider', () => { JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', highlights: ['a highlight'] }] }), { status: 200, headers: { 'content-type': 'application/json' } }, ))) - const out = await call('web_search', { query: 'deepseek-official' }) + const out = await call('web_search', { queries: ['deepseek-official'] }) expect(out.isError).toBe(false) expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[Result](https://result.test)') }) @@ -107,10 +107,11 @@ describe('tool-call timeout policy over the migrated web tools', () => { it('neither model schema exposes a timeout parameter after the migration', () => { const byName = new Map(ctx.tools.schemas().map(s => [s.name, s])) const fetchParams = byName.get('web_fetch')!.parameters as { properties: Record } - const searchParams = byName.get('web_search')!.parameters as { properties: Record } + const searchParams = byName.get('web_search')!.parameters as { properties: Record; required?: string[] } expect(Object.keys(fetchParams.properties)).toEqual(['url']) expect('timeout_ms' in fetchParams.properties).toBe(false) - expect(Object.keys(searchParams.properties)).toEqual(['query']) + expect(Object.keys(searchParams.properties)).toEqual(['queries']) + expect(searchParams.required).toEqual(['queries']) }) }) diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 5cfd43ba0b..2adad8b79c 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -10,7 +10,6 @@ import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import { formatSearchOutput, formatFetchOutput, - parseSearchArgs, parseFetchArgs, presentSearchCall, presentFetchCall, @@ -20,10 +19,12 @@ import { searchMetaFromResult, fetchMetaFromValue, fetchMetaFromResult, + WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ToolResult } from '@deepseek-ai/dsh-tools' +import { parseSearchArgs } from '../src/search.ts' const testToolSignal = new AbortController().signal @@ -84,9 +85,14 @@ describe('search formatting', () => { expect(out).toContain('Showing the first 1 sources') }) - it('validates the query', () => { - expect(() => parseSearchArgs({ query: ' ' })).toThrow('non-empty') - expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' }) + it('validates queries', () => { + expect(parseSearchArgs({ queries: ['hi'] }, WEB_SEARCH_MAX_QUERIES)).toEqual(['hi']) + expect(parseSearchArgs({ queries: ['one', 'one', ' two '] }, WEB_SEARCH_MAX_QUERIES)) + .toEqual(['one', ' two ']) + expect(() => parseSearchArgs({ queries: [] }, WEB_SEARCH_MAX_QUERIES)).toThrow('at least one query') + expect(() => parseSearchArgs({ queries: ['one', 'two'] }, 1)).toThrow('at most 1 query') + expect(() => parseSearchArgs({ queries: ['one', 'two', 'three'] }, 2)).toThrow('at most 2 queries') + expect(() => parseSearchArgs({ queries: ['ok', ' '] }, WEB_SEARCH_MAX_QUERIES)).toThrow('each query must be a non-empty string') }) it('falls back to the raw URL as a source label when the URL is unparseable', () => { @@ -94,8 +100,8 @@ describe('search formatting', () => { expect(out).toContain('[not a url](not a url)') }) - it('presents a search call as a search-kind card titled by the query', () => { - expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' }) + it('presents a search call with a joined query title', () => { + expect(presentSearchCall({ queries: ['one', 'two'] })).toEqual({ card: 'generic', title: 'one, two', kind: 'search', rawInput: 'one, two' }) }) }) @@ -145,7 +151,7 @@ describe('web_search presentation meta and result view', () => { content: 'an answer', truncated: true, sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], }) - expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({ + expect(presentSearchResult({ queries: ['q'] }, toolResult(meta, 'rendered'))).toEqual({ card: 'web', kind: 'search', title: 'q', @@ -157,7 +163,7 @@ describe('web_search presentation meta and result view', () => { it('omits the answer from the view when meta carries none', () => { const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) - const view = presentSearchResult({ query: 'q' }, toolResult(meta)) + const view = presentSearchResult({ queries: ['q'] }, toolResult(meta)) expect(view).toBeDefined() expect(view && 'answer' in view).toBe(false) expect(view && 'content' in view).toBe(false) @@ -165,11 +171,11 @@ describe('web_search presentation meta and result view', () => { it('falls back to the generic card on an error result', () => { const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) - expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined() + expect(presentSearchResult({ queries: ['q'] }, toolResult(meta, 'body', true))).toBeUndefined() }) it('falls back to the generic card on absent or malformed meta', () => { - expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined() + expect(presentSearchResult({ queries: ['q'] }, toolResult(undefined))).toBeUndefined() expect(searchMetaFromResult(undefined)).toBeUndefined() expect(searchMetaFromResult(null)).toBeUndefined() expect(searchMetaFromResult('nope')).toBeUndefined() @@ -445,7 +451,7 @@ describe('tool-web registration', () => { const names = ctx.tools.schemas().map(s => s.name) expect(names).toContain('web_search') expect(names).toContain('web_fetch') - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { queries: ['q'] } })) .toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) .toEqual({ kind: 'parallel' }) @@ -474,7 +480,7 @@ describe('tool-web registration', () => { expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search') // No provider is registered: the schema stays visible and execution reports // the structured unavailability instead. - const out = await call('web_search', { query: 'q' }) + const out = await call('web_search', { queries: ['q'] }) expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE') await fiber.dispose() }) @@ -483,7 +489,7 @@ describe('tool-web registration', () => { const { fiber, ctx } = await mountTools() const prompt = await ctx.systemPrompt.assemble() const text = prompt.sections.map(s => s.text).join('\n') - expect(text).toContain('Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.') + expect(text).toContain(`Use the web_search tool to discover current information on the web. The required queries array accepts 1–${WEB_SEARCH_MAX_QUERIES} non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.`) expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL') await fiber.dispose() }) @@ -505,25 +511,156 @@ describe('tool-web execution through the real registry', () => { sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], } const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) - const out = await call('web_search', { query: 'q' }) + const out = await call('web_search', { queries: ['q'] }) expect(out.isError).toBe(false) expect(out.value).toEqual(result) expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[A](https://a.test)') await fiber.dispose() }) + it('executes web_search with multiple queries concurrently and merges results', async () => { + const seen: string[] = [] + let releaseFirst: (() => void) | undefined + const firstResult = new Promise((resolve) => { + releaseFirst = () => { + resolve({ + content: 'answer one', truncated: false, + sources: [ + { url: 'https://a.test', title: 'A' }, + { url: 'https://shared.test' }, + ], + }) + } + }) + const provider: WebSearchProvider = { + id: 'stub-search', + available: () => available, + search: (request) => { + seen.push(request.query) + if (request.query === 'one') return firstResult + return Promise.resolve({ + content: 'answer two', truncated: false, + sources: [ + { url: 'https://b.test', title: 'B' }, + { url: 'https://shared.test' }, + ], + }) + }, + } + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + const pending = call('web_search', { queries: ['one', 'one', 'two'] }) + try { + await vi.waitFor(() => { expect(seen).toEqual(['one', 'two']) }) + } finally { + releaseFirst?.() + } + const out = await pending + expect(out.isError).toBe(false) + expect(out.value).toEqual({ + content: '### one\n\nanswer one\n\n### two\n\nanswer two', + sources: [ + { url: 'https://a.test', title: 'A' }, + { url: 'https://b.test', title: 'B' }, + { url: 'https://shared.test' }, + ], + truncated: false, + }) + const body = out.content.map(b => b.type === 'text' ? b.text : '').join('') + expect(body).toContain('### one') + expect(body).toContain('### two') + await fiber.dispose() + }) + + it('continues round-robin merging after a shorter result is exhausted', async () => { + const provider: WebSearchProvider = { + id: 'stub-search', + available: () => available, + search: request => Promise.resolve(request.query === 'one' + ? { content: '', sources: [{ url: 'https://a.test' }], truncated: false } + : { sources: [{ url: 'https://b.test' }, { url: 'https://c.test' }], truncated: false }), + } + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + const out = await call('web_search', { queries: ['one', 'two'] }) + expect(out.isError).toBe(false) + expect(out.value).toEqual({ + sources: [ + { url: 'https://a.test' }, + { url: 'https://b.test' }, + { url: 'https://c.test' }, + ], + truncated: false, + }) + await fiber.dispose() + }) + + it('aborts sibling searches and waits for them to settle before reporting a batch failure', async () => { + let siblingAborted = false + let releaseSibling: (() => void) | undefined + const provider: WebSearchProvider = { + id: 'stub-search', + available: () => available, + search: (request, signal) => { + if (request.query === 'one') return Promise.reject(new Error('first search failed')) + return new Promise((_resolve, reject) => { + releaseSibling = () => { reject(new Error('sibling search stopped')) } + signal?.addEventListener('abort', () => { + siblingAborted = true + }, { once: true }) + }) + }, + } + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + const pending = call('web_search', { queries: ['one', 'two'] }) + let callSettled = false + void pending.then(() => { callSettled = true }) + try { + await vi.waitFor(() => { expect(siblingAborted).toBe(true) }) + await Promise.resolve() + expect(callSettled).toBe(false) + } finally { + releaseSibling?.() + } + const out = await pending + expect(out.isError).toBe(true) + expect(out.content).toEqual([{ type: 'text', text: 'Error: first search failed' }]) + await fiber.dispose() + }) + + it('caps combined multi-query results to searchMaxResults', async () => { + const provider: WebSearchProvider = { + id: 'stub-search', + available: () => available, + search: request => Promise.resolve({ + sources: request.query === 'one' + ? [{ url: 'https://a.test' }, { url: 'https://b.test' }] + : [{ url: 'https://c.test' }, { url: 'https://d.test' }], + truncated: false, + }), + } + const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider }) + const out = await call('web_search', { queries: ['one', 'two'] }) + expect(out.isError).toBe(false) + expect(out.value).toEqual({ + sources: [{ url: 'https://a.test' }, { url: 'https://c.test' }], + truncated: true, + }) + const body = out.content.map(b => b.type === 'text' ? b.text : '').join('') + expect(body).toContain('Showing the first 2 sources.') + await fiber.dispose() + }) + it('projects the search sources into the tool result meta and derives its web/search view', async () => { const result: WebSearchResult = { content: 'answer', truncated: true, sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], } const { ctx, fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) - const out = await call('web_search', { query: 'q' }) + const out = await call('web_search', { queries: ['q'] }) expect(out.meta).toEqual({ answer: 'answer', truncated: true, sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], }) - const view = ctx.tools.get('web_search')?.presentResult?.({ query: 'q' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} }) + const view = ctx.tools.get('web_search')?.presentResult?.({ queries: ['q'] }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} }) expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' }) await fiber.dispose() }) @@ -546,7 +683,7 @@ describe('tool-web execution through the real registry', () => { it('surfaces a structured WebError when no provider is available', async () => { const { fiber, call } = await mountTools() - const out = await call('web_search', { query: 'q' }) + const out = await call('web_search', { queries: ['q'] }) expect(out.isError).toBe(true) expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE') await fiber.dispose() @@ -555,15 +692,15 @@ describe('tool-web execution through the real registry', () => { it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => { const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) }) ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) }) - const out = await call('web_search', { query: 'q' }) + const out = await call('web_search', { queries: ['q'] }) expect(out.isError).toBe(true) expect(out.error?.info?.code).toBe('WEB_PROVIDER_AMBIGUOUS') await fiber.dispose() }) - it('rejects invalid arguments with a structured INVALID_ARGS error', async () => { + it.each([{}, { queries: [123] }])('rejects absent or wrongly typed queries with a structured INVALID_ARGS error', async (args) => { const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) }) - const out = await call('web_search', { query: 123 }) + const out = await call('web_search', args) expect(out.isError).toBe(true) expect(out.error?.info?.code).toBe('INVALID_ARGS') await fiber.dispose() @@ -635,10 +772,34 @@ describe('tool-web execution through the real registry', () => { } const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) const controller = new AbortController() - await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal }) + await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { queries: ['q'] }, signal: controller.signal }) expect(seen.signal).toBe(controller.signal) await fiber.dispose() }) + + it('cascades caller cancellation to every multi-query search', async () => { + const signals: (AbortSignal | undefined)[] = [] + const provider: WebSearchProvider = { + id: 'stub-search', + available: () => available, + search: (_request, signal) => { + signals.push(signal) + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('search aborted')) }, { once: true }) + }) + }, + } + const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + const controller = new AbortController() + const pending = ctx.tools.execute({ callId: CallId('search-multi-1'), name: 'web_search', arguments: { queries: ['one', 'two'] }, signal: controller.signal }) + await vi.waitFor(() => { expect(signals).toHaveLength(2) }) + expect(signals[0]).toBe(signals[1]) + expect(signals[0]).not.toBe(controller.signal) + controller.abort(new Error('caller cancelled')) + await pending + expect(signals.every(signal => signal?.aborted === true)).toBe(true) + await fiber.dispose() + }) }) describe('searchMaxResults is plugin config', () => { @@ -650,7 +811,7 @@ describe('searchMaxResults is plugin config', () => { search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) }, } const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) - await call('web_search', { query: 'q' }) + await call('web_search', { queries: ['q'] }) expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS) await fiber.dispose() }) @@ -663,7 +824,7 @@ describe('searchMaxResults is plugin config', () => { search: () => Promise.resolve({ sources, truncated: false }), } const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider }) - const out = await call('web_search', { query: 'q' }) + const out = await call('web_search', { queries: ['q'] }) expect(out.isError).toBe(false) const body = out.content.map(b => b.type === 'text' ? b.text : '').join('') expect(body).toContain('https://s1.test') @@ -686,6 +847,43 @@ describe('searchMaxResults is plugin config', () => { }) }) +describe('searchMaxQueries is plugin config', () => { + it('exposes the configured cap to the model and enforces it before provider calls', async () => { + const seen: string[] = [] + const provider: WebSearchProvider = { + id: 'stub-search', + available: () => available, + search: (request) => { + seen.push(request.query) + return Promise.resolve({ sources: [], truncated: false }) + }, + } + const { fiber, ctx, call } = await mountTools({ + config: { searchMaxQueries: 2 }, + webConfig: { searchProvider: 'stub-search' }, + search: provider, + }) + const schema = ctx.tools.schemas().find(item => item.name === 'web_search') + expect(schema?.description).toContain('1–2 queries') + const prompt = await ctx.systemPrompt.assemble() + expect(prompt.sections.map(section => section.text).join('\n')).toContain('accepts 1–2 non-empty search queries') + const out = await call('web_search', { queries: ['one', 'two', 'three'] }) + expect(out.isError).toBe(true) + expect(out.content).toEqual([{ type: 'text', text: 'Error: queries must contain at most 2 queries' }]) + expect(seen).toEqual([]) + await fiber.dispose() + }) + + it.each([0, -1, 1.5])('rejects an invalid searchMaxQueries value %s at load', async (value) => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(WebRuntime, {}) + await expect(ctx.plugin(ToolWeb, { searchMaxQueries: value })) + .rejects.toThrow(/tool-web: searchMaxQueries must be a positive integer/) + }) +}) + describe('tool-call timeout budget is plugin config', () => { it('attaches the default 30s budget to web_fetch and web_search', async () => { const { fiber, ctx } = await mountTools() diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index 2c7fcb5fcd..9e94bac46b 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -8,9 +8,10 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** - * What one search-capable backend can return. The model-facing argument is just - * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged - * and enforced on the way back by the seam (see {@link WebSearchResult}). + * What one search-capable backend is asked to search. Each request carries one + * query; a consumer may issue several requests. `maxResults` is a + * `dsh-tool-web`-layer bound passed through unchanged and enforced on the way + * back by the seam (see {@link WebSearchResult}). */ export interface WebSearchRequest { readonly query: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f842c81aaa..c505d4ea60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7175,6 +7175,12 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7209,9 +7215,15 @@ importers: packages/subagent/subagent-codex: dependencies: + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@openai/codex': + specifier: 0.147.0 + version: 0.147.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7231,9 +7243,6 @@ importers: '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../test-support/loader-smoke - '@deepseek-ai/dsh-sdk-protocol': - specifier: workspace:^ - version: link:../../sdk/protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7249,9 +7258,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - '@openai/codex': - specifier: 0.147.0 - version: 0.147.0 packages/subagent/subagent-dsh-sdk: dependencies: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 5852839961..8b98333ce2 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -87,6 +87,11 @@ export interface PackageManifest { devDependencies?: Record dependencies?: Record optionalDependencies?: Record + dsh?: { + bundle?: { + patch?: string + } + } } /** One workspace manifest and its repo-relative path. */ @@ -134,10 +139,6 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib. - '@deepseek-ai/dsh-base': ['cordis.patch.yml'], - '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], - '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], // Statically linked client libraries keep their stylesheets next to the emitted // JavaScript, which imports them by relative path: the compile shell runs // them through its own CSS pipeline, so the sheets are published artifacts. @@ -164,7 +165,12 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl } function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { - const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] + const declaredPatch = manifest.dsh?.bundle?.patch + const bundleFiles = declaredPatch === undefined ? [] : [declaredPatch.replace(/^\.\//, '')] + const extras = [ + ...bundleFiles, + ...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []), + ] return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts new file mode 100644 index 0000000000..749a63d6a7 --- /dev/null +++ b/scripts/coverage-partitions.spec.ts @@ -0,0 +1,232 @@ +import { access, mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + COVERAGE_PARTITION_MODE_ENV, + COVERAGE_PARTITIONS_ENV, + COVERAGE_TEST_TIMEOUT_ENV, + CoveragePartitionCoordinator, + coverageTestTimeoutArgs, + forwardedCoverageArgs, + parseCoveragePartitionCount, + type CoverageCommand, + type CoverageCommandResult, +} from './coverage-partitions.ts' + +const passed: CoverageCommandResult = { exitCode: 0, signalCode: null } + +afterEach(() => vi.restoreAllMocks()) + +async function writeBlob(command: CoverageCommand): Promise { + if (command.blobPath === undefined) return + await mkdir(dirname(command.blobPath), { recursive: true }) + await writeFile(command.blobPath, '{}') +} + +async function temporaryRoot(): Promise { + return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-')) +} + +describe('coverage partition count', () => { + it.each([ + [undefined, undefined], + ['', undefined], + ['2', 2], + ['3', 3], + ])('parses %j as %j', (raw, expected) => { + expect(parseCoveragePartitionCount(raw)).toBe(expected) + }) + + it.each(['0', '1', '2.5', '02', 'many'])('rejects %j', (raw) => { + expect(() => parseCoveragePartitionCount(raw)) + .toThrow(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1`) + }) +}) + +describe('coverage partition timeout', () => { + it('applies one configured timeout to tests and polling', () => { + expect(coverageTestTimeoutArgs('30000')).toEqual([ + '--testTimeout=30000', + '--expect.poll.timeout=30000', + ]) + }) + + it('keeps Vitest defaults when the timeout is absent', () => { + expect(coverageTestTimeoutArgs(undefined)).toEqual([]) + }) + + it('rejects invalid timeout input', () => { + expect(() => coverageTestTimeoutArgs('0')) + .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`) + }) +}) + +describe('coverage forwarded arguments', () => { + it('removes one package-script separator', () => { + expect(forwardedCoverageArgs(['--', 'scripts/example.spec.ts'])).toEqual(['scripts/example.spec.ts']) + }) + + it('preserves direct arguments and a subsequent Vitest separator', () => { + expect(forwardedCoverageArgs(['--testNamePattern=example'])).toEqual(['--testNamePattern=example']) + expect(forwardedCoverageArgs(['--', '--', 'example'])).toEqual(['--', 'example']) + }) +}) + +describe('coverage partition coordinator', () => { + it('runs every single-worker partition before one merged threshold check', async () => { + const root = await temporaryRoot() + const commands: CoverageCommand[] = [] + const runCommand = vi.fn(async (command: CoverageCommand) => { + commands.push(command) + await writeBlob(command) + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 3, + pnpmEntrypoint: '/pnpm.cjs', + vitestArgs: ['--testTimeout=30000'], + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(0) + + expect(commands.map(command => command.label)).toEqual([ + 'partition 1/3', + 'partition 2/3', + 'partition 3/3', + 'merged coverage report', + ]) + for (const [index, command] of commands.slice(0, 3).entries()) { + expect(command.args).toEqual(expect.arrayContaining([ + '--coverage', + '--coverage.reportOnFailure', + '--maxWorkers=1', + `--shard=${index + 1}/3`, + '--reporter=default', + '--reporter=blob', + '--testTimeout=30000', + ])) + expect(command.env).toEqual({ + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: '1', + }) + } + const mergeCommand = commands[3] + if (mergeCommand === undefined) throw new Error('coverage merge command was not observed') + expect(mergeCommand.args).toContain('--coverage') + expect(mergeCommand.args.some(argument => argument.startsWith('--merge-reports='))).toBe(true) + expect(mergeCommand.env).toEqual({ + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: undefined, + }) + }) + + it('merges normal test failures and returns their failed status', async () => { + const root = await temporaryRoot() + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const runCommand = vi.fn(async (command: CoverageCommand) => { + await writeBlob(command) + return command.label === 'partition 2/2' + ? { exitCode: 1, signalCode: null, outputTail: 'specific Vitest failure' } + : passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(1) + expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (exit 1)') + expect(reported).toHaveBeenCalledWith( + 'coverage-partitions: output tail for partition 2/2:\nspecific Vitest failure', + ) + expect(runCommand).toHaveBeenCalledTimes(3) + }) + + it('rejects a missing partition blob before merge', async () => { + const root = await temporaryRoot() + const runCommand = vi.fn(async (command: CoverageCommand) => { + if (command.label !== 'partition 2/2') await writeBlob(command) + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).rejects.toThrow('coverage partitions produced') + expect(runCommand).toHaveBeenCalledTimes(2) + }) + + it('reports signal termination before missing-blob validation', async () => { + const root = await temporaryRoot() + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const runCommand = vi.fn(async (command: CoverageCommand) => { + if (command.label === 'partition 1/2') await writeBlob(command) + return command.label === 'partition 2/2' + ? { exitCode: null, signalCode: 'SIGTERM' as const } + : passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).rejects.toThrow('coverage partitions produced') + expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (signal SIGTERM)') + }) + + it('waits for every partition after one spawn failure', async () => { + const root = await temporaryRoot() + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + let secondFinished = false + const runCommand = vi.fn(async (command: CoverageCommand) => { + await writeBlob(command) + if (command.label === 'partition 1/2') { + return { exitCode: null, signalCode: null, error: 'spawn unavailable' } + } + if (command.label === 'partition 2/2') secondFinished = true + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(1) + expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 1/2 (spawn unavailable)') + expect(secondFinished).toBe(true) + expect(runCommand).toHaveBeenCalledTimes(3) + }) + + it('unlinks a link-shaped coverage path without touching its target', async () => { + const root = await temporaryRoot() + const target = await temporaryRoot() + const marker = join(target, 'marker.txt') + await writeFile(marker, 'owned elsewhere') + await symlink(target, join(root, 'coverage'), process.platform === 'win32' ? 'junction' : 'dir') + const runCommand = vi.fn(async (command: CoverageCommand) => { + await writeBlob(command) + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(0) + await expect(access(marker)).resolves.toBeUndefined() + }) +}) diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts new file mode 100644 index 0000000000..d9abc06101 --- /dev/null +++ b/scripts/coverage-partitions.ts @@ -0,0 +1,269 @@ +/** Coordinate single-worker Vitest coverage partitions and one merged report. */ +import { spawn } from 'node:child_process' +import { lstat, mkdir, readdir, rm, unlink } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' + +/** Environment variable selecting the number of instrumented coverage processes. */ +export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS' + +/** Internal marker that suppresses reports and thresholds inside a partition process. */ +export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE' + +/** Environment variable overriding instrumented test and polling timeouts. */ +export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS' + +/** One child command owned by the coverage coordinator. */ +export interface CoverageCommand { + /** Diagnostic identity. */ + label: string + /** Node arguments; the first argument is pnpm's JavaScript entrypoint. */ + args: string[] + /** Environment additions for the child. */ + env: Record + /** Working directory for the child. */ + cwd: string + /** Blob the partition must produce; absent for the merge command. */ + blobPath?: string +} + +/** Observable child-process completion. */ +export interface CoverageCommandResult { + /** Numeric process status, or `null` when a signal ended the child. */ + exitCode: number | null + /** Terminating signal, or `null` after an ordinary exit. */ + signalCode: NodeJS.Signals | null + /** Spawn failure recorded independently from process completion. */ + error?: string + /** Bounded combined stdout/stderr tail repeated when the command fails. */ + outputTail?: string +} + +/** Execute one coordinator command with inherited output. */ +export type CoverageCommandRunner = (command: CoverageCommand) => Promise + +/** Construction inputs for {@link CoveragePartitionCoordinator}. */ +export interface CoveragePartitionCoordinatorOptions { + /** Repository root that owns coverage output. */ + root: string + /** Number of concurrent single-worker Vitest processes. */ + partitions: number + /** pnpm JavaScript entrypoint from `npm_execpath`. */ + pnpmEntrypoint: string + /** Additional arguments shared by every partition. */ + vitestArgs?: string[] + /** Child executor, injectable for scheduler tests. */ + runCommand?: CoverageCommandRunner +} + +/** Parse an optional coverage partition count. */ +export function parseCoveragePartitionCount(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 2 || String(parsed) !== raw) { + throw new Error(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1, got ${JSON.stringify(raw)}.`) + } + return parsed +} + +/** Resolve the paired Vitest timeout arguments used by coverage partitions. */ +export function coverageTestTimeoutArgs(raw: string | undefined): string[] { + if (raw === undefined || raw === '') return [] + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) + } + return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`] +} + +/** Remove pnpm's package-script separator before forwarding Vitest arguments. */ +export function forwardedCoverageArgs(args: readonly string[]): string[] { + return [...args.slice(args[0] === '--' ? 1 : 0)] +} + +/** Run instrumented partitions, validate their blobs, and merge once. */ +export class CoveragePartitionCoordinator { + private readonly root: string + private readonly partitions: number + private readonly pnpmEntrypoint: string + private readonly vitestArgs: string[] + private readonly runCommand: CoverageCommandRunner + private readonly temporaryRoot: string + private readonly blobsRoot: string + + /** Create a coordinator from validated process-independent inputs. */ + public constructor(options: CoveragePartitionCoordinatorOptions) { + if (!Number.isSafeInteger(options.partitions) || options.partitions < 2) { + throw new Error(`coverage partitions must be an integer greater than 1, got ${String(options.partitions)}.`) + } + this.root = options.root + this.partitions = options.partitions + this.pnpmEntrypoint = options.pnpmEntrypoint + this.vitestArgs = options.vitestArgs ?? [] + this.runCommand = options.runCommand ?? runCoverageCommand + this.temporaryRoot = join(this.root, 'coverage', '.partitioned') + this.blobsRoot = join(this.temporaryRoot, 'blobs') + } + + /** + * Run every partition before one merged threshold check. + * @returns zero only when every partition and the merge command succeed. + */ + public async run(): Promise { + await removeOwnedTree(join(this.root, 'coverage')) + await mkdir(this.blobsRoot, { recursive: true }) + + try { + const commands = Array.from( + { length: this.partitions }, + (_, index) => this.partitionCommand(index + 1), + ) + const results = await Promise.all(commands.map(async (command) => { + console.log(`coverage-partitions: start ${command.label}`) + const result = await this.runCommand(command) + if (commandFailed(result)) { + console.error(`coverage-partitions: FAIL ${command.label} (${commandFailureReason(result)})`) + if (result.outputTail !== undefined && result.outputTail !== '') { + console.error(`coverage-partitions: output tail for ${command.label}:\n${result.outputTail}`) + } + } + return result + })) + await this.assertCompleteBlobSet(commands) + + const mergeCommand = this.mergeCommand() + console.log(`coverage-partitions: start ${mergeCommand.label}`) + const mergeResult = await this.runCommand(mergeCommand) + return results.some(commandFailed) || commandFailed(mergeResult) ? 1 : 0 + } finally { + await removeOwnedTree(this.temporaryRoot) + } + } + + private partitionCommand(index: number): CoverageCommand { + const blobPath = join(this.blobsRoot, `partition-${index}.json`) + const reportsDirectory = join(this.temporaryRoot, `coverage-${index}`) + return { + label: `partition ${index}/${this.partitions}`, + args: [ + this.pnpmEntrypoint, + 'exec', + 'vitest', + 'run', + '--coverage', + '--coverage.reportOnFailure', + '--maxWorkers=1', + `--shard=${index}/${this.partitions}`, + '--reporter=default', + '--reporter=blob', + `--outputFile.blob=${this.relativePath(blobPath)}`, + `--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`, + ...this.vitestArgs, + ], + env: { + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: '1', + }, + cwd: this.root, + blobPath, + } + } + + private mergeCommand(): CoverageCommand { + return { + label: 'merged coverage report', + args: [ + this.pnpmEntrypoint, + 'exec', + 'vitest', + `--merge-reports=${this.relativePath(this.blobsRoot)}`, + '--coverage', + ], + env: { + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: undefined, + }, + cwd: this.root, + } + } + + private relativePath(path: string): string { + return relative(this.root, path).split(sep).join('/') + } + + private async assertCompleteBlobSet(commands: CoverageCommand[]): Promise { + const expected = commands.map((command) => { + if (command.blobPath === undefined) throw new Error(`${command.label} has no blob path.`) + return this.relativePath(command.blobPath) + }).sort() + const actual = (await readdir(this.blobsRoot)) + .map(name => this.relativePath(join(this.blobsRoot, name))) + .sort() + if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) { + throw new Error(`coverage partitions produced ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}.`) + } + } +} + +/** Spawn one pnpm-backed command without a platform shell. */ +function runCoverageCommand(command: CoverageCommand): Promise { + return new Promise((resolveCommand) => { + let outputTail = '' + const env = { ...process.env } + for (const [name, value] of Object.entries(command.env)) { + if (value === undefined) Reflect.deleteProperty(env, name) + else env[name] = value + } + const child = spawn(process.execPath, command.args, { + cwd: command.cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + process.stdout.write(chunk) + outputTail = appendOutputTail(outputTail, chunk) + }) + child.stderr.on('data', (chunk: string) => { + process.stderr.write(chunk) + outputTail = appendOutputTail(outputTail, chunk) + }) + child.once('error', (error: Error) => { + resolveCommand({ exitCode: null, signalCode: null, error: error.message, outputTail }) + }) + child.once('close', (exitCode, signalCode) => { + resolveCommand({ exitCode, signalCode, outputTail }) + }) + }) +} + +function appendOutputTail(previous: string, chunk: string): string { + const combined = previous + chunk + return combined.length <= 65_536 ? combined : combined.slice(-65_536) +} + +function commandFailed(result: CoverageCommandResult): boolean { + return result.exitCode !== 0 || result.signalCode !== null || result.error !== undefined +} + +function commandFailureReason(result: CoverageCommandResult): string { + const facts = [ + result.error, + result.exitCode === null ? undefined : `exit ${result.exitCode}`, + result.signalCode === null ? undefined : `signal ${result.signalCode}`, + ].filter((fact): fact is string => fact !== undefined) + return facts.join(', ') || 'no exit code or signal' +} + +async function removeOwnedTree(path: string): Promise { + const metadata = await lstat(path).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined + throw error + }) + if (metadata === undefined) return + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + await unlink(path) + return + } + await rm(path, { recursive: true, force: true }) +} diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index f2411f5eff..3362251f1e 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -673,7 +673,6 @@ export function render(): string { ) ? collectClaudeDistribution() : undefined - const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) // A copyleft license reaching a shipped surface is a distribution decision, // not a rendering detail; the notices cannot quietly absorb it. @@ -693,7 +692,7 @@ export function render(): string { DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock). diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 56ca6315d6..f0c76ead6f 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -529,7 +529,12 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { const lockPath = installLockPath(fixture) const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running') const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' }) - await waitForPath(runningPath) + try { + await waitForPath(runningPath) + } catch (error) { + await install + throw error + } const replacementRecord = 'replacement owner\n' writeFileSync(lockPath, replacementRecord) diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts new file mode 100644 index 0000000000..b51630f105 --- /dev/null +++ b/scripts/locale-dictionary-parity.spec.ts @@ -0,0 +1,308 @@ +/** + * Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary + * declares the same keys in `zh` and `en`. + * + * The locale runtime resolves a key through the active locale, then through + * the single fallback locale (`en`), then surfaces the key itself. With + * symmetric dictionaries that middle step always resolves, so one constant can + * serve as both the opening locale and the dictionary fallback. A key added to + * only one side breaks that: a reader of the other language sees a bare key + * such as `list.aria` instead of text. This gate fails on the asymmetry rather + * than waiting for the bare key to reach a UI. + * + * Discovery is deliberately broad, because a gate that silently narrows is + * worse than no gate. It sweeps every workspace package (not just + * `packages/client`), reads dictionaries wherever they are declared — + * `locales.ts`, a `locales/` directory, or inline in the plugin body — and + * pairs `zh`/`en` across sibling files as well as within one module. A `zh` + * dictionary whose `en` counterpart cannot be found anywhere is an error, not + * a skip. + */ + +import type { Dirent } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */ +function relative(file: string): string { + return file.slice(root.length).replaceAll('\\', '/') +} + +/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */ +function sourceFiles(): string[] { + const files: string[] = [] + const packagesRoot = resolve(root, 'packages') + for (const group of directories(packagesRoot)) { + for (const pkg of directories(resolve(packagesRoot, group))) { + walk(resolve(packagesRoot, group, pkg, 'src'), files) + } + } + return files.sort() +} + +/** Immediate subdirectory names, or none when the path is not a directory. */ +function directories(dir: string): string[] { + return readEntries(dir).filter(entry => entry.isDirectory()).map(entry => entry.name) +} + +/** + * Directory entries, treating only a genuinely absent directory as empty. + * Any other failure (`EACCES`, I/O) rethrows: silently reading it as "absent" + * would narrow the sweep and let the gate pass while checking less. + * @param dir - absolute directory path. + * @returns entries, or none when the directory does not exist. + */ +function readEntries(dir: string): Dirent[] { + try { + return readdirSync(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } +} + +function walk(dir: string, out: string[]): void { + for (const entry of readEntries(dir)) { + const full = resolve(dir, entry.name) + if (entry.isDirectory()) walk(full, out) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full) + } +} + +/** One discovered dictionary: which file and export name declared it. */ +interface Dictionary { + /** Repo-relative declaring file. */ + file: string + /** Export name, or the registration site for an inline literal. */ + name: string + /** Declared keys, sorted. */ + keys: string[] +} + +/** + * Keys of every top-level `export const = { ... }` object literal whose + * name identifies a locale dictionary, plus inline `register(ns, locale, {...})` + * literals. Read from the AST so the gate never executes package code. + * @param file - absolute path of a candidate module. + * @returns discovered dictionaries, keyed by locale-bearing name. + */ +function dictionariesIn(file: string): Dictionary[] { + const text = readFileSync(file, 'utf8') + // Cheap pre-filter: parsing every package source is wasteful. The pattern + // must admit every shape `localeOf` accepts, or a file would be skipped + // before parsing — the silent narrowing this gate exists to prevent. A bare + // `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold + // between `h` and an uppercase letter. + if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return [] + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) + const found: Dictionary[] = [] + const rel = relative(file) + + // Module-scope variable declarations, keyed by name. A 3-arg + // `register(NS, 'zh'|'en', dict)` whose third argument is an identifier — + // e.g. a local dictionary variable rather than an inline literal — resolves + // through here so the gate still verifies its symmetry. + const moduleConsts = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const decl of statement.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) { + moduleConsts.set(decl.name.text, decl.initializer) + } + } + } + + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue + const literal = unwrap(decl.initializer) + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (localeOf(decl.name.text) === undefined) continue + found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) }) + } + } + + // A 3-arg `register(ns, 'zh'|'en', dict)` call whose dictionary argument we + // cannot turn into an object literal. We refuse instead of skipping: a + // registration we cannot measure is exactly the silent narrowing this gate + // exists to catch. + const refuse = (ns: string, tag: string, why: string): never => { + throw new Error(`cannot verify register('${ns}', '${tag}', ...) in ${rel}: ${why}`) + } + + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair + // handed to a registration loop keys off the enclosing array; separate + // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the + // namespace argument, so the two calls pair with each other. + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression + const name = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isIdentifier(callee) && callee.text === 'register' ? 'register' : undefined + if (name === 'register' && node.arguments.length >= 3) { + const [ns, tag, dict] = node.arguments + if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return + if (tag.text !== 'zh' && tag.text !== 'en') return + const raw = unwrap(dict) + const literal = raw !== undefined && ts.isIdentifier(raw) + ? (() => { + const resolved = moduleConsts.get(raw.text) + return resolved === undefined ? undefined : unwrap(resolved) + })() + : raw + const why = raw !== undefined && ts.isIdentifier(raw) + ? `third argument ${raw.text} does not resolve to an inline or module-scope object literal` + : 'third argument is neither an object literal nor a resolvable dictionary variable' + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) { + // The dictionary argument must resolve to an object literal; the + // gate refuses rather than skips, so the symmetry it verifies never + // silently narrows. + refuse(ns.getText(source), tag.text, why) + } + const dictionary: ts.ObjectLiteralExpression = literal as ts.ObjectLiteralExpression + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) }) + } + } + if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { + const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + for (const element of node.elements) { + if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue + const [tag, dict] = element.elements + const literal = unwrap(dict) + if (tag === undefined || !ts.isStringLiteral(tag)) continue + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (tag.text !== 'zh' && tag.text !== 'en') continue + found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) }) + } + } + ts.forEachChild(node, visit) + } + visit(source) + return found +} + +/** Declared property names of an object literal, sorted. */ +function keysOf(literal: ts.ObjectLiteralExpression): string[] { + const keys: string[] = [] + for (const prop of literal.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + return keys.sort() +} + +/** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ +function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { + let current = node + while ( + current !== undefined + && (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) + ) { + current = current.expression + } + return current +} + +/** + * The locale a dictionary name declares, and the namespace-ish remainder that + * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, + * and `settingsZh`/`settingsEn` are the shapes this repo uses. A name-prefix + * shape requires an uppercase ASCII letter at the third position (`[A-Z]`), + * matching the admission of the cheap pre-filter, so `zh2Foo`/`zh_probe` + * cannot be treated as dictionaries in one place and skipped in another. + * @param name - export name or synthetic inline name. + * @returns locale plus pair key, or undefined when the name names no locale. + */ +function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined { + for (const locale of ['zh', 'en'] as const) { + const other = locale === 'zh' ? 'Zh' : 'En' + if (name === locale) return { locale, pair: '' } + // Synthetic names for inline shapes carry their own pair key after the + // first ':' (the enclosing array's line, or the namespace expression). + if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } + if (name.startsWith(locale) && name.length > 2 && /[A-Z]/.test(name[2] ?? '')) { + return { locale, pair: name.slice(2) } + } + if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } + } + return undefined +} + +describe('shipped locale dictionaries', () => { + it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { + const files = sourceFiles() + // Guard the discovery itself: an empty or narrowed sweep would pass every + // assertion below while checking nothing. + expect(files.length).toBeGreaterThan(500) + + // Pair within a file first; a dictionary whose counterpart is not in the + // same module then pairs with a sibling in the same directory. Both shapes + // ship here: `locales/settings.ts` exports zh+en together, while + // `locales/zh.ts` + `locales/en.ts` split the common pair across files. + const perFile = new Map() + for (const file of files) { + const dicts = dictionariesIn(file) + if (dicts.length > 0) perFile.set(relative(file), dicts) + } + + const groups = new Map>() + const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => { + const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>() + if (slot.has(locale)) { + throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`) + } + slot.set(locale, dict) + groups.set(key, slot) + } + + for (const [rel, dicts] of perFile) { + for (const dict of dicts) { + const parsed = localeOf(dict.name) + if (parsed === undefined) continue + const sameFileCounterpart = dicts.some((other) => { + const otherParsed = localeOf(other.name) + return otherParsed !== undefined + && otherParsed.pair === parsed.pair + && otherParsed.locale !== parsed.locale + }) + // Same-file pairs key by file so two pairs in one directory stay + // distinct; split pairs key by directory so siblings meet. + const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}` + place(key, parsed.locale, dict) + } + } + + const problems: string[] = [] + let comparedPairs = 0 + for (const [key, slot] of [...groups].sort()) { + const zh = slot.get('zh') + const en = slot.get('en') + if (zh === undefined || en === undefined) { + const present = zh ?? en + problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`) + continue + } + comparedPairs++ + const zhOnly = zh.keys.filter(k => !en.keys.includes(k)) + const enOnly = en.keys.filter(k => !zh.keys.includes(k)) + if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`) + } + + // The shipped dictionary count only grows; a collapse means discovery or + // pairing broke, which would hide real asymmetry. + expect(comparedPairs).toBeGreaterThan(25) + expect(problems).toEqual([]) + }) +}) diff --git a/scripts/run-coverage-partitions.ts b/scripts/run-coverage-partitions.ts new file mode 100644 index 0000000000..8626665b96 --- /dev/null +++ b/scripts/run-coverage-partitions.ts @@ -0,0 +1,30 @@ +/** CLI entry for partitioned Vitest coverage. */ +import { resolve } from 'node:path' +import { + COVERAGE_PARTITIONS_ENV, + COVERAGE_TEST_TIMEOUT_ENV, + CoveragePartitionCoordinator, + coverageTestTimeoutArgs, + forwardedCoverageArgs, + parseCoveragePartitionCount, +} from './coverage-partitions.ts' + +const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV]) +if (partitions === undefined) { + throw new Error(`${COVERAGE_PARTITIONS_ENV} is required by partitioned coverage.`) +} +const pnpmEntrypoint = process.env.npm_execpath +if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') { + throw new Error('partitioned coverage must be invoked through a pnpm package script.') +} + +const coordinator = new CoveragePartitionCoordinator({ + root: resolve(import.meta.dirname, '..'), + partitions, + pnpmEntrypoint, + vitestArgs: [ + ...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]), + ...forwardedCoverageArgs(process.argv.slice(2)), + ], +}) +process.exitCode = await coordinator.run() diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d1de2914e8..dce448b2e0 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -101,13 +101,25 @@ describe('gate graph validation', () => { }, ) - it('keeps native Windows coverage blocking while portability inventory remains observational', () => { - const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')) - const byId = new Map(gates.map(subject => [subject.id, subject])) + it('keeps native Windows coverage blocking while retaining the observational inventory', () => { + const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')) + const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational')) + .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build') + const byId = new Map(complete.map(subject => [subject.id, subject])) expect(byId.get('coverage')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) - expect(byId.get('duplication')?.allowFailure).toBe(true) + expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build') + expect(observational).not.toHaveLength(0) + for (const gate of observational) { + const completeGate = byId.get(gate.id) + expect(completeGate?.allowFailure).toBe(true) + expect(completeGate?.after).toEqual(expect.arrayContaining([ + 'coverage', + 'coverage-exempt-heavy', + ])) + expect(completeGate?.needs).toEqual(gate.needs) + } }) it('applies one configured test and polling timeout to both coverage gates', () => { @@ -139,11 +151,30 @@ describe('gate graph validation', () => { .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer') }) + it('selects partitioned coverage only when explicitly configured', () => { + const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage'))) + + expect(coverage).toMatchObject({ + displayCommand: 'DSH_COVERAGE_PARTITIONS=3 pnpm run test:coverage:partitioned', + args: ['/private/pnpm.cjs', 'run', 'test:coverage:partitioned'], + streamOutput: true, + }) + }) + + it('rejects an invalid coverage partition count before starting a gate', () => { + expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))) + .toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1') + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/], + ['unknown ordering predecessors', [gate('subject', { after: ['missing'] })], /waits for unknown gate "missing"/], ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/], + ['mixed cycles', [gate('first', { after: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/], ] as const)('rejects %s before starting a child', async (_label, invalid, message) => { const execute = vi.fn(async (subject: Gate) => resultFor(subject)) @@ -169,6 +200,29 @@ describe('gate graph validation', () => { expect(execute).toHaveBeenCalledWith(root) expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' }) }) + + it('runs an ordered follower after its predecessor fails', async () => { + const follower = gate('follower', { after: ['root'] }) + const root = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed')) + + const results = await runGates([follower, root], 2, execute) + + expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower']) + expect(results.map(result => result.status)).toEqual(['passed', 'failed']) + }) + + it('runs an ordered follower after its predecessor is skipped', async () => { + const follower = gate('follower', { after: ['dependent'] }) + const dependent = gate('dependent', { needs: ['root'] }) + const root = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed')) + + const results = await runGates([follower, dependent, root], 2, execute) + + expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower']) + expect(results.map(result => result.status)).toEqual(['passed', 'skipped', 'failed']) + }) }) describe('Oxlint gate', () => { @@ -290,7 +344,7 @@ describe('Node 24 lane ownership', () => { 'built-bin-smoke', ]) expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) - expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of [ 'snapshot', @@ -332,6 +386,22 @@ describe('Linux primary graph', () => { }) describe('gate process outcomes', () => { + it('streams selected gate output without retaining it', async () => { + const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + try { + const result = await runGate(gate('streamed', { + args: ['-e', "process.stdout.write('live output')"], + streamOutput: true, + })) + + expect(result.status).toBe('passed') + expect(result.output).toEqual([]) + expect(write).toHaveBeenCalledWith('live output') + } finally { + write.mockRestore() + } + }) + it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => { const result = await runGate(gate('terminated', { args: ['-e', "process.kill(process.pid, 'SIGTERM')"], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d26fa77320..1f65fed97e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -10,6 +10,12 @@ import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' +import { + COVERAGE_PARTITIONS_ENV, + COVERAGE_TEST_TIMEOUT_ENV, + coverageTestTimeoutArgs, + parseCoveragePartitionCount, +} from './coverage-partitions.ts' /** A named aggregate exposed by the gate runner. */ export type Mode = @@ -39,8 +45,13 @@ export interface Gate { command: string args: string[] needs?: string[] + /** Gate ids that must settle, regardless of outcome, before this gate starts. */ + after?: string[] env?: Record + /** Keep a failure visible without failing the aggregate. */ allowFailure?: boolean + /** Write child output as it arrives instead of buffering it until completion. */ + streamOutput?: boolean } /** The observed outcome of one gate process. */ @@ -395,7 +406,7 @@ function ciConsumerGates(): Gate[] { pnpmScript('build', 'build'), pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), pnpmScript('publint', 'publint', { needs: builtTree }), - builtPackageInvariantsGate(['publint']), + builtPackageInvariantsGate(builtTree), pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', { label: 'lint and duplication', needs: validatedBuild, @@ -415,6 +426,20 @@ function ciConsumerGates(): Gate[] { } function webSnapshotGate(needs: string[]): Gate { + const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS + if (workerRaw !== undefined && workerRaw !== '') { + const workers = Number.parseInt(workerRaw, 10) + if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) { + throw new Error(`run-gates: DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`) + } + return pnpmScript('web-snapshot', 'test:web:ci', { + label: 'web browser snapshot', + displayCommand: `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=${workers} pnpm run test:web:ci`, + env: { DSH_SNAPSHOT: 'replay' }, + needs, + streamOutput: true, + }) + } return pnpmScript('web-snapshot', 'test:web:built', { label: 'web browser snapshot', displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', @@ -431,15 +456,23 @@ function ciWindowsBlockingGates(): Gate[] { } function ciWindowsCompleteGates(): Gate[] { + const coverage = coverageGates().map(gate => gate.id === 'coverage-exempt-heavy' + ? { ...gate, needs: [...new Set(['build', ...(gate.needs ?? [])])] } + : gate) + const coverageAfter = coverage.map(gate => gate.id) const observational = ciWindowsObservationalGates() // The required production site replaces the observational MPA build; both // VitePress modes write the same output directory and cannot overlap. .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build') - .map(gate => ({ ...gate, allowFailure: true })) + .map(gate => ({ + ...gate, + allowFailure: true, + after: [...new Set([...coverageAfter, ...(gate.after ?? [])])], + })) return [ pnpmScript('build', 'build'), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), - ...coverageGates(), + ...coverage, ...observational, ] } @@ -479,13 +512,14 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // under v8 instrumentation while contributing nothing the thresholds need // (membership rules in scripts/coverage-exempt.ts). // -// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel -// gates split it instead of each claiming it whole (the failover pool's -// 8 x 6-instance bound assumes one lane never exceeds its value). The exempt +// DSH_COVERAGE_MAX_WORKERS is the ordinary lane's worker budget, so the two +// parallel gates split it instead of each claiming it whole. When +// DSH_COVERAGE_PARTITIONS is set, its single-worker processes replace the +// instrumented share while this budget still sizes the exempt gate. The exempt // gate's wall clock is dominated by its longest single file, so it takes the -// small share. A budget of 1 gives each gate 1 worker; lanes that need a -// strict total of one (the serial reference jobs) also set -// DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all. +// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict +// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1, +// which keeps the gates from overlapping at all. // DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll // defaults together for instrumented lanes whose scheduling overhead exceeds // those defaults. Explicit fixture timeouts remain authoritative. @@ -501,18 +535,12 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { } } -function coverageTimeoutArgs(): string[] { - return [ - ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--testTimeout'), - ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--expect.poll.timeout'), - ] -} - function coverageGates(): Gate[] { const workers = coverageWorkerArgs() - const timeouts = coverageTimeoutArgs() - return [ - pnpmExec('coverage', [ + const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]) + const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV]) + const instrumented = partitions === undefined + ? pnpmExec('coverage', [ 'vitest', 'run', '--coverage', @@ -521,7 +549,15 @@ function coverageGates(): Gate[] { ], { label: 'test:coverage', env: { [COVERAGE_EXEMPT_ENV]: '1' }, - }), + }) + : pnpmScript('coverage', 'test:coverage:partitioned', { + label: 'test:coverage', + displayCommand: `${COVERAGE_PARTITIONS_ENV}=${partitions} pnpm run test:coverage:partitioned`, + env: { [COVERAGE_EXEMPT_ENV]: '1' }, + streamOutput: true, + }) + return [ + instrumented, pnpmExec('coverage-exempt-heavy', [ 'vitest', 'run', @@ -681,6 +717,11 @@ function validateGateGraph(gates: readonly Gate[]): void { throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`) } } + for (const predecessor of gate.after ?? []) { + if (!ids.has(predecessor)) { + throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} waits for unknown gate ${JSON.stringify(predecessor)}.`) + } + } } const cycle = findDependencyCycle(gates) @@ -702,8 +743,8 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { active.set(id, path.length) path.push(id) - for (const dependency of gate.needs ?? []) { - const cycle = visit(dependency) + for (const predecessor of [...(gate.needs ?? []), ...(gate.after ?? [])]) { + const cycle = visit(predecessor) if (cycle !== undefined) return cycle } path.pop() @@ -744,7 +785,7 @@ export async function runGates( for (;;) { let madeProgress = false while (running.length < maxActive) { - const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) + const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states)) if (ready === undefined) break states.set(ready.id, 'running') running.push({ gate: ready, promise: execute(ready) }) @@ -753,32 +794,24 @@ export async function runGates( } if (running.length === 0) { - let pending = gates.filter(gate => states.get(gate.id) === 'pending') - while (pending.length > 0) { - const gate = pending.find(item => (item.needs ?? []).some((id) => { - const state = states.get(id) - return state === 'failed' || state === 'skipped' - })) - if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.') - const failedDeps = (gate.needs ?? []).filter((id) => { - const state = states.get(id) - return state === 'failed' || state === 'skipped' - }) - const result: GateResult = { - gate, - status: 'skipped', - durationMs: 0, - output: [], - exitCode: null, - signalCode: null, - error: `dependency failed or skipped: ${failedDeps.join(', ')}`, - } - states.set(gate.id, 'skipped') - results.set(gate.id, result) - observe(result) - pending = pending.filter(item => item !== gate) + const pending = gates.filter(gate => states.get(gate.id) === 'pending') + if (pending.length === 0) break + const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id)))) + if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.') + const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id))) + const result: GateResult = { + gate, + status: 'skipped', + durationMs: 0, + output: [], + exitCode: null, + signalCode: null, + error: `dependency failed or skipped: ${failedDeps.join(', ')}`, } - break + states.set(gate.id, 'skipped') + results.set(gate.id, result) + observe(result) + continue } if (!madeProgress) { @@ -797,8 +830,17 @@ export async function runGates( }) } -function dependenciesPassed(gate: Gate, states: Map): boolean { +function predecessorsReady(gate: Gate, states: Map): boolean { return (gate.needs ?? []).every(id => states.get(id) === 'passed') + && (gate.after ?? []).every(id => gateSettled(states.get(id))) +} + +function gateSettled(state: GateState | undefined): boolean { + return state === 'passed' || state === 'failed' || state === 'skipped' +} + +function gateFailed(state: GateState | undefined): boolean { + return state === 'failed' || state === 'skipped' } /** @@ -823,10 +865,12 @@ export async function runGate(gate: Gate): Promise { child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (chunk: string) => { - output.push({ stream: 'stdout', text: chunk }) + if (gate.streamOutput === true) process.stdout.write(chunk) + else output.push({ stream: 'stdout', text: chunk }) }) child.stderr.on('data', (chunk: string) => { - output.push({ stream: 'stderr', text: chunk }) + if (gate.streamOutput === true) process.stderr.write(chunk) + else output.push({ stream: 'stderr', text: chunk }) }) child.on('error', (error) => { spawnError = `failed to start command: ${error.message}` @@ -880,7 +924,7 @@ function printResult(result: GateResult): void { console.error(`command: ${result.gate.displayCommand}`) console.error(`outcome: ${formatGateResultReason(result)}`) } - printOutput(result.output) + if (result.gate.streamOutput !== true) printOutput(result.output) } function printSummary(results: GateResult[], durationMs: number): void { diff --git a/scripts/run-web-snapshots.ts b/scripts/run-web-snapshots.ts new file mode 100644 index 0000000000..c73047085c --- /dev/null +++ b/scripts/run-web-snapshots.ts @@ -0,0 +1,48 @@ +/** Run serial browser owners before one bounded snapshot pool. */ +import { spawn } from 'node:child_process' + +const serialFiles = [ + 'apps/web/tests/hmr-live.e2e.ts', + 'apps/web/tests/cordis-tool-round.e2e.ts', +] +const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS +const workers = Number.parseInt(workerRaw ?? '', 10) +if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) { + throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`) +} +const pnpmEntrypoint = process.env.npm_execpath +if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') { + throw new Error('parallel web snapshots must be invoked through a pnpm package script.') +} + +const baseArgs = [pnpmEntrypoint, 'exec', 'vitest', 'run', '--config', 'vitest.web.config.ts'] +let serialStatus = 0 +for (const file of serialFiles) { + serialStatus = await run([...baseArgs, file]) + if (serialStatus !== 0) break +} +if (serialStatus === 0) { + process.exitCode = await run([ + ...baseArgs, + ...serialFiles.map(file => `--exclude=${file}`), + '--fileParallelism', + `--maxWorkers=${String(workers)}`, + ]) +} else { + process.exitCode = serialStatus +} + +function run(args: string[]): Promise { + return new Promise((resolveRun, reject) => { + const child = spawn(process.execPath, args, { stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (exitCode, signalCode) => { + if (signalCode !== null) { + console.error(`web snapshots terminated by ${signalCode}`) + resolveRun(1) + return + } + resolveRun(exitCode ?? 1) + }) + }) +} diff --git a/scripts/verify-config-source-ownership.spec.ts b/scripts/verify-config-source-ownership.spec.ts index 41026c5fe4..3a099156c2 100644 --- a/scripts/verify-config-source-ownership.spec.ts +++ b/scripts/verify-config-source-ownership.spec.ts @@ -14,7 +14,7 @@ describe('configuration source ownership gate', () => { it('rejects inline endpoints in shipped bundle patches', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-config-source-ownership-')) roots.push(root) - const directory = join(root, 'packages/bundle/base') + const directory = join(root, 'packages/subagent/subagent-claude-code') mkdirSync(directory, { recursive: true }) writeFileSync( join(directory, 'cordis.patch.yml'), @@ -22,7 +22,7 @@ describe('configuration source ownership gate', () => { ) expect(collectConfigSourceOwnershipViolations(root)).toEqual([ - 'packages/bundle/base/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + 'packages/subagent/subagent-claude-code/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + ' environment snapshot; inlining here bypasses both ladders.', ]) diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index 0684124215..c42e7cb793 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -14,7 +14,8 @@ const SHIPPED_CONFIG_GLOBS = [ 'apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml', - 'packages/bundle/*/cordis.patch.yml', + // Bundle identity comes from the package manifest, not the domain directory. + 'packages/*/*/cordis.patch.yml', // The Python runtime ships its own default composition inside the wheel. 'python/*/src/**/cordis.yml', ] diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index 6c1304e16a..f63031e46c 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -4,8 +4,15 @@ * metadata field must stay static, and a disabled expression must parse. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { metadataExpressionErrors } from './verify-cordis-config.ts' +import { + bundleManifestPaths, + bundlePluginDependencyErrors, + metadataExpressionErrors, +} from './verify-cordis-config.ts' describe('verify-cordis-config metadata expressions', () => { it('accepts a disabled !!js expression', () => { @@ -37,3 +44,45 @@ describe('verify-cordis-config metadata expressions', () => { expect(problems.some(problem => problem.includes('[0].disabled: disabled expression does not parse'))).toBe(true) }) }) + +describe('workspace Bundle discovery and product dependency closures', () => { + it('discovers a Bundle outside packages/bundle from its manifest declaration', () => { + const fixture = mkdtempSync(join(tmpdir(), 'dsh-bundle-discovery-')) + try { + const bundleDir = join(fixture, 'packages/subagent/example') + const plainDir = join(fixture, 'packages/bundle/plain') + mkdirSync(bundleDir, { recursive: true }) + mkdirSync(plainDir, { recursive: true }) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-subagent-example', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(plainDir, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-plain', + })) + + expect(bundleManifestPaths(fixture)).toEqual([ + 'packages/subagent/example/package.json', + ]) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }) + + it('allows a Bundle to mount itself but rejects an undeclared plugin package', () => { + const manifestPath = 'packages/subagent/example/package.json' + const file = 'packages/subagent/example/cordis.patch.yml' + const manifest = { + name: '@deepseek-ai/dsh-subagent-example', + dependencies: {}, + } + const self = { file, name: '@deepseek-ai/dsh-subagent-example' } + expect(bundlePluginDependencyErrors(manifestPath, manifest, [self])).toEqual([]) + expect(bundlePluginDependencyErrors(manifestPath, manifest, [ + self, + { file, name: '@deepseek-ai/dsh-missing-plugin' }, + ])).toEqual([ + `${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies`, + ]) + }) +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index f2e0aef46c..281be99f50 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -20,12 +20,14 @@ interface JsExpr { __jsExpr: string } -interface PackageManifest { +export interface PackageManifest { name?: string dependencies?: Record + optionalDependencies?: Record + dsh?: { bundle?: { patch?: string } } } -interface PluginReference { +export interface PluginReference { file: string name: string } @@ -260,11 +262,14 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const violations: string[] = [] + const bundleManifests = bundleManifestPaths() // App overlays (and any config left under apps/cli/config) resolve from the // dsh app's own dependency surface — the profile module fallback mirrors it. const appDependencies = { ...readManifest('apps/cli/package.json').dependencies, - // The fallback also links every bundle's own dependencies (healProfilesModuleFallback). + // The fallback also links every in-box bundle's own dependencies + // (healProfilesModuleFallback). Optional Profile bundles stay outside the + // app installation until that Profile installs them. ...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root }) .flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))), } @@ -274,20 +279,50 @@ function validateAppResolution(): string[] { violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest')) // Each bundle's patch rows must resolve from that bundle's own dependencies: // per-layer resolution anchors on the bundle package directory. - for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) { + for (const manifestPath of bundleManifests) { const bundleDir = manifestPath.replace(/\/package\.json$/, '') const manifest = readManifest(manifestPath) - const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`)) - violations.push(...missingPluginDependencies( - // A bundle may mount its own package (the web-app runtime row). - references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), - manifest.dependencies ?? {}, - manifestPath, - )) + const patch = manifest.dsh?.bundle?.patch + if (typeof patch !== 'string') continue + const patchFile = relative(root, resolve(root, bundleDir, patch)).replaceAll('\\', '/') + const references = pluginReferences.filter(reference => reference.file === patchFile) + violations.push(...bundlePluginDependencyErrors(manifestPath, manifest, references)) } return violations } +/** + * Discover workspace Bundle packages from their manifest declaration. + * @param repoRoot Repository root to scan. + * @returns Sorted slash-normalized repository-relative package manifest paths. + */ +export function bundleManifestPaths(repoRoot: string = root): string[] { + return globSync('packages/*/*/package.json', { cwd: repoRoot }) + .filter(path => typeof readManifest(path, repoRoot).dsh?.bundle?.patch === 'string') + .map(path => path.replaceAll('\\', '/')) + .sort() +} + +/** + * Validate plugin packages referenced by one Bundle patch. + * @param manifestPath Repository-relative Bundle manifest path. + * @param manifest Parsed Bundle manifest. + * @param references Plugin rows read from the Bundle package directory. + * @returns Missing production dependency diagnostics. + */ +export function bundlePluginDependencyErrors( + manifestPath: string, + manifest: PackageManifest, + references: readonly PluginReference[], +): string[] { + return missingPluginDependencies( + // A Bundle may mount its own package (for example, its provider or runtime row). + references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), + manifest.dependencies ?? {}, + manifestPath, + ) +} + /** * Every configured specifier of a local workspace package must resolve through * the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source @@ -363,8 +398,8 @@ function missingPluginDependencies( : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`) } -function readManifest(path: string): PackageManifest { - return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest +function readManifest(path: string, repoRoot: string = root): PackageManifest { + return JSON.parse(readFileSync(resolve(repoRoot, path), 'utf8')) as PackageManifest } function localPackageDirectories(): Map { diff --git a/tsconfig.host.json b/tsconfig.host.json index 0986b4df59..7068684eca 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,11 +13,13 @@ "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", + "apps/web/tests/startup-rpc-budget.e2e.ts", "apps/web/tests/minimal-preset.snapshot.ts", "apps/web/tests/message-feedback-protocol.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", + "apps/web/tests/plan-control-row.e2e.ts", "apps/web/tests/plan-review.e2e.ts", "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 1c351fab6e..b255083cb8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ import { resolvePwshPath } from './packages/shell/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' +import { COVERAGE_PARTITION_MODE_ENV } from './scripts/coverage-partitions.ts' // Prints exact `path:line:col` records for every uncovered statement, branch // path, and function when a file misses the per-file 100% gate — the built-in @@ -100,6 +101,12 @@ const coverageExemptExcludes = coverageExemptRaw === '1' ? coverageExemptHeavySuites.map(suite => suite.exclude) : [] +const coveragePartitionRaw = process.env[COVERAGE_PARTITION_MODE_ENV] +if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coveragePartitionRaw !== '1') { + throw new Error(`vitest config: ${COVERAGE_PARTITION_MODE_ENV} must be '1' or unset, got ${JSON.stringify(coveragePartitionRaw)}.`) +} +const coveragePartitionMode = coveragePartitionRaw === '1' + // These suites exercise process-global state, process APIs, or timing-sensitive process I/O // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. @@ -270,16 +277,20 @@ export default defineConfig({ // Per-file so a well-covered big file can't subsidize a bare one. // Every v8 ignore comment must carry a reason — see the quality-gates Agent Note // (.agents/notes/implemented/process/2026-06-11-quality-gates.md). - thresholds: { - perFile: true, - statements: 100, - branches: 100, - functions: 100, - lines: 100, - }, - reporter: process.env.CI - ? ['text', uncoveredLocationsReporter] - : ['text', 'html', uncoveredLocationsReporter], + thresholds: coveragePartitionMode + ? undefined + : { + perFile: true, + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + reporter: coveragePartitionMode + ? [] + : process.env.CI + ? ['text', uncoveredLocationsReporter] + : ['text', 'html', uncoveredLocationsReporter], }, }, }) diff --git a/vitest.web.config.ts b/vitest.web.config.ts index 1179144f61..7c20ab6462 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -27,7 +27,8 @@ export default defineConfig({ 'apps/web/tests/**/*.e2e.ts', 'apps/web/tests/**/*.snapshot.ts', ], - // Browser boot + real-model turns are slow; files share one browser, run serial. + // Local and record runs stay serial. CI runs workspace-mutating HMR and + // dynamic Cordis lifecycle coverage before parallelizing the remaining files. testTimeout: 180_000, hookTimeout: 120_000, fileParallelism: false,