diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index f1d2fe1a90..dc88be13a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.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 -2026-06-13-twin-llm-adapters.md: 5c3308b281ce71407002e95dd6e794da2a421fa8 -2026-06-13-twin-llm-adapters.zh.md: 93b084973bccaeb802508e4e939a259c281f2608 +2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2 +2026-06-13-twin-llm-adapters.zh.md: d98b57a0a2e7c92453046022e8cb50aa52994c0f diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md index 5c3308b281..b922891d44 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -12,10 +12,10 @@ English | [中文](2026-06-13-twin-llm-adapters.zh.md) Ship **two** adapters against the one contract from the start, deliberately built on different internals: -- `dsh-llm-deepseek` — hand-rolled `fetch` + SSE parsing against the DeepSeek API. +- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the SSE-parser swap](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing. - `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary). -The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single hand-rolled adapter would have hidden. +The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single direct-fetch adapter would have hidden. ## Alternatives considered @@ -24,4 +24,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for ## Consequences -The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the hand-rolled adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note. +The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the direct-fetch adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note. diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 93b084973b..d98b57a0a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -12,10 +12,10 @@ Status: implemented 从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建: -- `dsh-llm-deepseek`:手写 `fetch` + SSE(Server-Sent Events)解析,直接对接 DeepSeek API。 +- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 - `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 -二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏。 +二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 ## 曾考虑的替代方案 @@ -24,4 +24,4 @@ Status: implemented ## 后果 -孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。 +孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;直接 fetch 适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 51d0eb9a78..690aa1f9b4 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-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 -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58 -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 556d5dd86dfcc92c4628e68c19390f0033560d25 +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9d67797f86903e70e7bdcd6f80f19d17c41ac18e diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 284cd45a66..556d5dd86d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -18,7 +18,7 @@ Three deliberate choices: 1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. -2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision manages `DSH_*`: ambient entries are removed, and trusted `dshEnv` merges last, so an ordinary `env` entry can never displace a managed value. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 9486f8c35c..9d67797f86 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -18,7 +18,7 @@ Status: implemented 1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策托管 `DSH_*`:环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 6d14977e92..471cf9f92d 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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 -2026-07-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 -2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891 +2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de +2026-07-06-timeout-deadline-library.zh.md: c3d3cdf1c63813fc24c10727e42d326142f3f4de diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 11d4b8cd48..63463a76a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 334914c689..c3d3cdf1c6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml index 6eeebc9848..cb487f67a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-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 -2026-07-20-routed-model-context-and-compaction-policy.md: b637ba24d4ba5fc25c8cdd515a821ee97883a326 -2026-07-20-routed-model-context-and-compaction-policy.zh.md: 084e762ec29ddc0aecb0bf422c147b9d3122726b +2026-07-20-routed-model-context-and-compaction-policy.md: b00c744e4ac79983a1f492d710e7b1542278c4f4 +2026-07-20-routed-model-context-and-compaction-policy.zh.md: 88e26aa7a99e8deb434d9a8fd80784be410a999a diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md index b637ba24d4..b00c744e4a 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md @@ -14,9 +14,9 @@ Neither obvious configuration owner is sufficient. Compact-basic is optional and ### Adapters own exact-route capacity -`LlmAdapter.resolveModelContext(provider, model)` optionally returns `LlmModelContext` for one exact route. `LlmService.resolveModelContext()` selects the registered route owner, validates a positive integer `contextWindow`, and returns a detached value. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and `undefined` means only that the adapter cannot describe capacity. +`LlmAdapter.resolveModel(provider, model, signal?)` returns aggregate metadata for one exact route, with optional `LlmModelContext` under its `context` field. `LlmService.resolveModelInfo()` selects the registered route owner, validates a positive integer `contextWindow`, and returns detached metadata. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and an absent `context` means only that the adapter cannot describe capacity. -The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or return `undefined` when it is absent. The two built-in model entries each publish an exact 128,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model. +The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or omit `context` when it is absent. The two built-in model entries each publish an exact 128,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model. ### Token measurement remains model-agnostic diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md index 084e762ec2..88e26aa7a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md @@ -14,9 +14,9 @@ Status: implemented ### 适配器拥有精确路由容量 -`LlmAdapter.resolveModelContext(provider, model)` 可以为一条精确路由返回 `LlmModelContext`。`LlmService.resolveModelContext()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离值。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而 `undefined` 只表示适配器无法描述容量。 +`LlmAdapter.resolveModel(provider, model, signal?)` 返回一条精确路由的聚合元数据,其中可选的 `LlmModelContext` 位于 `context` 字段下。`LlmService.resolveModelInfo()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离的元数据。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而缺少 `context` 只表示适配器无法描述容量。 -手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则返回 `undefined`。两个内置模型项都公开精确的 128,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 +手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。两个内置模型项都公开精确的 128,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 ### Token 计量保持模型无关 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 6de82d1c9b..2cba925d67 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.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 -2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2 -2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126 +2026-07-23-toolview-dissolution.md: 80c2688b152d1afe1236d4815633a5bf024db1d2 +2026-07-23-toolview-dissolution.zh.md: 928c5f445d601b2246d3ae2f9360232643814468 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index a420c5945d..80c2688b15 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. +Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`, with a scoped badge only in child sessions). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index 47c1f392f5..928c5f445d 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -14,7 +14,7 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 +落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`,scoped badge 仅出现在子会话)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml similarity index 58% rename from .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml index 0a31f7a857..e9adb2cd8b 100644 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.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 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 63e3f45ab2340ee2b732da286117e25be45bed08 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 2348e07d58f7f0ed39a1759cc30133c8e15dbc4a +2026-07-24-adapter-owned-reasoning-effort-capabilities.md: cc66e4ec151fcc04445a91f4a3527cbddd130c33 +2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: e0d28e1aca370068478e8fb1704defeaac3ab351 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md new file mode 100644 index 0000000000..cc66e4ec15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md @@ -0,0 +1,33 @@ +# Agent Note: Adapter-owned reasoning effort capabilities + +Status: implemented + +English | [中文](2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md) + +## Problem + +Reasoning strength was adapter configuration only, so a conversation could not discover or change the selected model's supported levels between requests. Promoting one adapter's level union into `dsh-llm` would make every provider and model adopt names it may not support, while a provider-specific options bag would make the loop unable to validate or durably reconstruct the effective request. + +## Decision + +`dsh-llm` represents a reasoning effort as the opaque branded `ReasoningEffortId`. One adapter-owned `resolveModel(provider, model, signal?)` query returns `LlmResolvedModelInfo`: exact model identity plus optional context and reasoning metadata. `LlmService.resolveModelInfo()` validates and detaches that aggregate. When present, `reasoning.efforts` is a non-empty ordered list of ids with display metadata and may name one configured default. The core requires an explicit or configured effort to appear exactly in that list and never clamps or aliases a value. + +`LlmCallConfig` and `GenerateOptions` carry the optional effort. The agent loop prepares the post-`agent/request` config under the active turn signal before writing `request/header`, so defaults and dynamic changes are model-visible only after becoming durable facts. The prepared call retains the exact adapter registration across asynchronous exact-model resolution, durable header logging, and dispatch; direct `LlmService.stream()` calls likewise capture their final registration before awaiting resolution. A route with no registered adapter retains its proposed config so an `llm/stream` middleware can own and short-circuit it; terminal dispatch still rejects an unhandled route. A resumed loop retains the logged effort only when its initial provider/model route is unchanged; a route change discards the previous model's opaque id. + +The native DeepSeek adapter advertises `off`, `high`, and `max` when deployment policy permits thinking, and defaults to the configured effort or `high`. Its adapter-owned `off` maps to `thinking.type: disabled` with no `reasoning_effort`; `high` and `max` enable thinking and carry their official wire effort. A `thinking: disabled` deployment publishes only `off` and rejects attempts to enable thinking before provider I/O. The pi-ai adapter publishes each exact model's `getSupportedThinkingLevels()` result unchanged, including `off`, preserves an absent profile default as a provider default, and leaves provider wire-value mapping inside pi-ai. Its common stream options represent `off` by omitting `reasoning`, as required by pi-ai's own API. + +## Alternatives considered + +**Define the pi-ai `ThinkingLevel` union in core.** Rejected because current pi-ai canonical names are an adapter implementation detail; a future provider can expose a different identifier without requiring a core release. + +**Carry an untyped provider options object.** Rejected because the loop could neither validate a selected value nor put a stable provider-neutral fact in the request header. + +**Clamp unsupported levels.** Rejected because a silent substitution makes the user's selected control differ from the logged request intent and hides stale deployment configuration. + +**Normalize every adapter to a core-owned level list or remove `off`.** Rejected because the selectable vocabulary belongs to the exact model capability. A client can render an adapter's `off` option without requiring every adapter to expose it. + +## Consequences + +Clients can query one exact route once and render its identity, context capacity, and adapter-owned reasoning choices without knowing a global enum or synthesizing `off`. Adapter configuration remains the deployment-default and policy owner, while `agent/request` can replace the effective effort on each step within that policy. Invalid exact identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`; unsupported explicit or configured values fail with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. + +The aggregate exact-model query is asynchronous and may fail for adapters backed by authoritative catalogs. Its optional signal is the caller's cancellation boundary; an asynchronous adapter must settle promptly after abort so loop disposal can reach quiescence. Keyless service, adapter, loop, session, and request-header tests pin validation, defaulting, dynamic changes, logging, resume behavior, HMR registration ownership, and cancellation; runnable snapshots pin the resolved effort in real assembled request headers, while key-gated adapter tests exercise provider serialization. diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md new file mode 100644 index 0000000000..e0d28e1aca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md @@ -0,0 +1,33 @@ +# Agent Note:适配器持有的推理强度能力 + +Status: implemented + +[English](2026-07-24-adapter-owned-reasoning-effort-capabilities.md) | 中文 + +## 问题 + +推理强度过去只能在适配器中配置,因此对话无法在多次请求之间发现或更改所选模型支持的等级。若将某个适配器的等级联合类型提升到 `dsh-llm`,所有提供方和模型都必须采用一套自身可能并不支持的名称;若改用提供方特有的 options 对象,主循环又无法校验最终生效的请求,也无法通过持久化记录准确重建该请求。 + +## 决策 + +`dsh-llm` 使用不透明的品牌类型 `ReasoningEffortId` 表示推理强度。由适配器持有的单次 `resolveModel(provider, model, signal?)` 查询返回 `LlmResolvedModelInfo`,其中包含确切模型身份以及可选的上下文和推理元数据。`LlmService.resolveModelInfo()` 会校验该聚合结果并返回分离值。`reasoning.efforts` 存在时,是包含展示元数据的非空有序 ID 列表,并可指定一个由配置确定的默认值。核心要求显式指定或配置指定的推理强度与列表中的某个 ID 完全一致,且绝不自动调整或为值提供别名。 + +`LlmCallConfig` 和 `GenerateOptions` 携带可选的推理强度。agent loop(智能体循环)在活跃轮次信号的控制下准备 `agent/request` 处理完成后的配置,再写入 `request/header`,因此默认值和动态变更只有成为持久化事实后才对模型可见。准备完成的调用在异步确切模型解析、请求头持久记录和分派全程保留同一项确切的适配器注册;直接调用 `LlmService.stream()` 时,也会在等待解析前捕获最终的适配器注册。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 中间件可以接管并短路该请求;若仍未得到处理,最终分发会拒绝该路由。恢复后的主循环仅在初始提供方/模型路由未变时保留日志中记录的推理强度;如果路由发生变化,则丢弃上一模型的不透明 ID。 + +当部署策略允许思考时,原生 DeepSeek 适配器声明 `off`、`high` 和 `max`,默认使用配置指定的推理强度,若未配置则使用 `high`。由适配器持有的 `off` 映射为 `thinking.type: disabled`,且不带 `reasoning_effort`;`high` 和 `max` 会启用思考并携带各自的官方协议强度值。配置为 `thinking: disabled` 的部署仅声明 `off`,并会在提供方 I/O 前拒绝启用思考的尝试。pi-ai 适配器原样发布每个确切模型的 `getSupportedThinkingLevels()` 结果,其中包括 `off`;profile 未指定默认值时保留提供方默认行为,并将提供方协议值的映射留在 pi-ai 内部。按照 pi-ai 自身 API 的要求,其通用流选项通过省略 `reasoning` 来表示 `off`。 + +## 备选方案 + +**在核心中定义 pi-ai 的 `ThinkingLevel` 联合类型。** 不予采纳:pi-ai 当前的规范名称属于适配器实现细节;未来的提供方可以暴露不同的标识符,而无需为此发布新的核心版本。 + +**携带无类型约束的提供方 options 对象。** 不予采纳:主循环既无法校验选定值,也无法在请求头中写入稳定且与提供方无关的事实。 + +**自动调整不支持的等级。** 不予采纳:静默替换会导致用户选定的控制项与日志记录的请求意图不一致,还会掩盖陈旧的部署配置。 + +**将每个适配器规范化为核心持有的等级列表,或移除 `off`。** 不予采纳:可选值集合属于确切模型的能力。客户端可以渲染某个适配器的 `off` 选项,而无需要求所有适配器都暴露该选项。 + +## 影响 + +客户端只需查询一次确切路由,即可渲染其身份、上下文容量和由适配器持有的推理选项,而无需了解全局枚举或自行合成 `off`。适配器配置仍是部署默认值和策略的归属方,`agent/request` 则可以在该策略范围内为每个步骤替换实际生效的推理强度。确切身份、上下文或推理元数据无效时,分别抛出 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING`;显式指定或配置指定的值不受支持时,会在提供方 I/O 前抛出 `UNSUPPORTED_REASONING_EFFORT`。 + +确切模型元数据的聚合查询采用异步方式,并且对于由权威目录支持的适配器可能失败。可选信号构成调用方的取消边界;异步适配器必须在信号中止后迅速完成结算,使主循环的资源释放达到完全停稳。无密钥的服务、适配器、主循环、会话和请求头测试为校验、默认值解析、动态变更、日志记录、恢复行为、HMR(热模块替换)期间的注册所有权和取消提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。 diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml similarity index 61% rename from .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 90cad79b89..82047e9d87 100644 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.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 -2026-07-26-execa-for-test-subprocess-plumbing.md: 99a86258fe4d59db6a0e144dbcee94c095f70f8f -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 525e09f07ce3e5dc61f1cadab5c11ea0790cccee +2026-07-26-subprocess-consumer-migration.md: b31f69f1251a7219e168ed7800ba16ff7d6b328c +2026-07-26-subprocess-consumer-migration.zh.md: 47e7519a72f5482f255d15d87b483e9295f6cd2c diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md new file mode 100644 index 0000000000..b31f69f125 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess seam goes Node-shaped and every eligible spawner rides it + +Status: implemented + +English | [中文](2026-07-26-subprocess-consumer-migration.zh.md) + +## Problem + +The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable. + +## Decision + +The seam's vocabulary is now Node-shaped, and every spawner that can ride the service does: + +- **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). +- **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. +- **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).) +- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. + +Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). + +Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row. + +## Alternatives considered + +**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams. + +**A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases. + +**Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put. + +**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead. + +## Consequences + +Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. + +Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md new file mode 100644 index 0000000000..47e7519a72 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入 + +Status: implemented + +[English](2026-07-26-subprocess-consumer-migration.md) | 中文 + +## 问题 + +[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。 + +## 决策 + +这道 seam 的词汇如今已是 Node 形状,凡能接入该服务的 spawn 调用点均已迁入: + +- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 +- **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 +- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。) +- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 + +各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 + +挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 + +## 曾考虑的替代方案 + +**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 + +**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。 + +**把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 + +**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 + +## 后果 + +换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 + +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml similarity index 60% rename from .agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index ced514a423..6c6b1d6b44 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-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 -2026-07-26-turndown-for-tool-web-html-markdown.md: 7f25e51bf6e6fc9313a880abee737bca80a472af -2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 3a59b08e13fd392e4f34ac543f32f5b4648f3c1c +2026-07-26-subprocess-seam.md: ad2f8522be51ba16b0df155aeb334a88f493890f +2026-07-26-subprocess-seam.zh.md: d9a0fb56b57b545dd1f94fde0cfb436d58fe00d4 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md new file mode 100644 index 0000000000..ad2f8522be --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess service is its own seam under the bash executors (`dsh-subprocess` / `dsh-subprocess-local`) + +Status: implemented + +English | [中文](2026-07-26-subprocess-seam.zh.md) + +## Problem + +`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. + +## Decision + +A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: + +- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. (The [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) later widened the stdio and termination vocabulary Node-ward.) +- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the explicit-env merge after it, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. +- **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. +- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. + +Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). + +Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta. + +## Alternatives considered + +**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split. + +**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk at this PR's scale: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam shipped proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule. Review then asked for exactly that follow-up as a stacked PR; the [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) records the Node-ward reshape and which sites moved (and which stayed, by ownership). + +**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry. + +**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the subprocess service.** Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the ambient scrub (credential-shaped and `DSH_*` names) is a security/identity invariant and stays, but terminal friendliness is the bash tool's choice, expressed through the spec's explicit env where a caller's own entry still wins. + +## Consequences + +Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-subprocess-local` (argv-based, plus argv-validation and service lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, service-owned lifetime) against the real service. + +Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the subprocess service leaves `ctx.bash` pending on `ctx.subprocess` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the subprocess seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md new file mode 100644 index 0000000000..d9a0fb56b5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`) + +Status: implemented + +[English](2026-07-26-subprocess-seam.md) | 中文 + +## 问题 + +`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 + +## 决策 + +新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: + +- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除与 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 其后将 stdio 与终止词汇拓宽为 Node 形状。) +- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、清除之后合并显式 env 的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 +- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 + +如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 + +后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 + +## 曾考虑的替代方案 + +**把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。 + +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在本 PR(Pull Request)的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。评审随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留在原地)。 + +**改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 + +**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。 + +## 后果 + +换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 + +代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml new file mode 100644 index 0000000000..bd5964f1a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.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 +2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 +2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md new file mode 100644 index 0000000000..97b551ff50 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -0,0 +1,23 @@ +# Agent Note: The dispose ladder belongs to its consumer, not the subprocess seam + +Status: implemented + +English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) + +## Problem + +`SubprocessHandle.dispose(graces)` and `SubprocessDisposeGraces` put a full teardown *policy* — stdin-EOF wait, then SIGTERM, then SIGKILL, each tier bounded by a caller-supplied window — on a seam whose other verbs are single mechanisms. Only one consumer ever called it (the ACP subagent backend); bash rides `terminate()` and service teardown, and the LSP host runs its own protocol-first shutdown. Every future backend nonetheless had to implement the ladder to satisfy the interface, and the implementation carried a `dsh-timeout` dependency solely for the ladder's tier bounds. + +## Decision + +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. + +## Alternatives considered + +**Keep the ladder on the handle as a convenience.** Rejected: a seam method every implementation must provide is not a convenience, it is contract surface — and this one encodes one consumer's cooperation shape (stdin-EOF-first) as if it were process vocabulary. The seam's own README already had to caveat that children quiescing on other signals need "their own tier-1", which is the admission that the ladder is policy. + +**Move the ladder to a shared helper package.** Rejected: one consumer. A second out-of-process backend with the same stdin-EOF cooperation shape can lift `disposeAcpChild` to shared code when it exists; extracting now would recreate `dsh-subagent-subprocess`, the single-purpose library this stack just deleted. + +## Consequences + +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md new file mode 100644 index 0000000000..b6849ad393 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -0,0 +1,23 @@ +# Agent Note: dispose 阶梯归其消费方所有,而非 subprocess seam + +Status: implemented + +[English](2026-07-27-dispose-ladder-to-consumer.md) | 中文 + +## 问题 + +`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。 + +## 决策 + +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 + +## 曾考虑的替代方案 + +**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。 + +**把阶梯移到共享辅助包。**否决:只有一个消费方。当第二个具有相同 stdin EOF 配合形状的进程外后端出现时,可以再把 `disposeAcpChild` 提升为共享代码;现在抽取只会重造 `dsh-subagent-subprocess`——这组堆叠变更刚刚删掉的那个单一用途库。 + +## 后果 + +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index d7babf16b3..be023bf4c7 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.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 -2026-06-29-todo-write-tool.md: df1bee2801b0e01b290b63f6edbe2e5b1be80cb7 -2026-06-29-todo-write-tool.zh.md: 7fa5cb2aad2b32ef0662df04ff6576be14a3a8e7 +2026-06-29-todo-write-tool.md: 760373d64f462e3717a174d5793e6d47ab76b0b4 +2026-06-29-todo-write-tool.zh.md: fd29e6049e6a729c2c7e78860ad7c065422da60e diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index df1bee2801..760373d64f 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -10,7 +10,7 @@ The harness gives the model bash and subagent tools but no way to record a struc ## Decision -Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event; the TUI folds it directly, while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation. +Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event: the TUI folds it directly, the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)), while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation. ### Whole-list replace, three-state status @@ -18,7 +18,7 @@ The model sends the entire list every call; the new list replaces the old (last- ### State on the session log, not a service -The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. +The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).) ### NOT a surface event diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 7fa5cb2aad..fd29e6049e 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -10,7 +10,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ## 决策 -新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染;TUI 直接折叠它,而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意省略 todo 展示。 +新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染:TUI 直接折叠它,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.md)),而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意省略 todo 展示。 ### 整列表替换,三态 status @@ -18,7 +18,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ### 状态在会话日志上,而非服务 -列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。 +列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建;web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。) ### 不是 surface 事件 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 6437d7813d..ee6efc69f4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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 -2026-07-06-sandbox.md: 723ef170188dc11da24e049a1e2838fb240d0a17 -2026-07-06-sandbox.zh.md: a8c7743bb3d499fb58f507ea2c202b44efe2311d +2026-07-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148 +2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 723ef17018..c688387319 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index a8c7743bb3..d84df9b06b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——对安全不变式而言,它还远未经过实战检验。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index 2e7b5b1d58..f756be1482 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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 -2026-07-10-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c -2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac +2026-07-10-agent-session-identity-and-log-location.md: 7b51ae41ac00c12a496940c891092580003646fa +2026-07-10-agent-session-identity-and-log-location.zh.md: 2574e1327f424069cdff68ef9b8de20c490077f0 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index a55bf276bf..7b51ae41ac 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -40,7 +40,7 @@ The registry rebuilds a trusted overlay for every foreground and background bash Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. -The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. +The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for ambient filtering. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`: ordinary `env` remains the general in-process plugin surface used by hooks, while `dshEnv` is typed to managed keys. The local executor removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot, so an `env` entry can never displace a managed value. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. @@ -82,6 +82,6 @@ A keyless full-loop integration drives the real agent loop, JSONL persistence, t ## Consequences -Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks. +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 84b8b22187..2574e1327f 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -40,7 +40,7 @@ interface SessionPersistence { 会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 -bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 +bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量过滤环境中已有的值。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层:普通 `env` 仍是钩子所用的通用进程内插件接口,`dshEnv` 则以类型约束为受管理键。本地执行器移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照,因此 `env` 条目永远无法顶掉受管理的值。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 @@ -82,6 +82,6 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 ## 影响 -每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。 +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index f7e242b78c..34e6c14df6 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -1,6 +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 -2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95 -2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +2026-07-16-persistent-pty-sessions.md: 4fff1742721fa13ea11f1b8ec833e5e5b7e68df8 +2026-07-16-persistent-pty-sessions.zh.md: 88e7a0bc4b8af21dc51b6a654a1c9f93760b3e81 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 148d4a2f47..4fff174272 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -80,6 +80,8 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle` Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session. +Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed. + `node-pty` data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application. ### Model-visible output and durability @@ -154,7 +156,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. -- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. +- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface. - The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 9a9d9cd4b0..88e7a0bc4b 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 +一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 + `node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 ### 模型可见输出与持久性 @@ -154,7 +156,7 @@ plugins: - 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 -- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。 +- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。 - 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 79a4f5e7cc..6d44658ecc 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.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 -2026-07-17-dedicated-full-screen-tui-front-door.md: aac67ffec89606d04d5abfd233d0469e2241b102 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f64e2ce14b18d315f75913b82c731e77758e377 +2026-07-17-dedicated-full-screen-tui-front-door.md: 8d7c7b00c8d9b15ea3f2419ed44ca88209e60dad +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 49e9541c9f98c9f3beba11945ff452fc38bd9ede diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index aac67ffec8..8d7c7b00c8 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -22,9 +22,9 @@ The selected front door receives the exact generated or resumed `SessionId` used The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. -The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership @@ -49,4 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. -- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. +- Model and reasoning-effort selection use adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 0f64e2ce14..49e9541c9f 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -22,9 +22,9 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 -`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 @@ -49,4 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 -- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 +- 模型和推理强度选择使用适配器公布的元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1ff9ecac7d..1f52492649 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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 -2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7 -2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a +2026-07-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281 +2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index ce98a16fa4..38d193271d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -12,13 +12,17 @@ The Web conversation preserves assistant Markdown source through session events, `@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. -`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle. +`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. + +Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Citation pills, KaTeX, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers are out of scope until matching product DOM exists; GFM task lists keep native checkboxes. + +The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. ## Untrusted output policy -Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. +Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). -The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. +Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. ## Alternatives considered @@ -30,6 +34,8 @@ The renderer uses existing `--dsw-*` typography and color tokens. Fenced code an **Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. +**Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. + ## Consequences -Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision. +Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, and shiki allowlist; cite/math/anchor/thinking-small surfaces remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 0d6fd2f9e6..be3cd041c6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -12,13 +12,17 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 -`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML,也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分。 +`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 + +视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。引用胶囊、KaTeX、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记均不在范围内,直至存在匹配的产品 DOM;GFM 任务列表继续使用原生复选框。 + +该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。 +assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 -渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码块与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 +围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 ## 考虑过的替代方案 @@ -30,6 +34,8 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 + ## 后果 -assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器与 GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策。 +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时与 shiki 允许列表;cite/math/anchor/thinking-small 表层仍暂缓。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml new file mode 100644 index 0000000000..5c8530da70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.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/feature/2026-07-23-web-todo-display.md +2026-07-23-web-todo-display.md: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d +2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md new file mode 100644 index 0000000000..5fe08cc40c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -0,0 +1,36 @@ +# Agent Note: Web todo display — snapshot side-effect channel + two render surfaces + +Status: implemented + +English | [中文](2026-07-23-web-todo-display.zh.md) + +## Problem + +`todo_write` appends `todo/write` whole-list snapshots to the session log; the TUI renders a persistent plan panel (the automation-only ACP bridge deliberately omits todo presentation). The web client dropped the event entirely: the host mux stream already forwards every session event, but `todo/write` is not a surface type (it never folds into `ConversationSnapshot.nodes`), and no side-effect branch accumulated it — the browser had no consumption point and no display surface. + +## Decision + +Consume `todo/write` as a Session side effect, not a surface node, and render it on two surfaces matching the split the TUI already draws. + +### Side-effect channel, converging with window replay + +`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins). Unlike partial/openCalls, `rebuildDerivedFromWindow` deliberately does NOT reset it: the value is session-level — taken from the tail history page's full-log projection — and an arbitrary window may not contain the latest write, so an older-page prepend keeps it and only an in-window or live write overwrites it. Every `installWindow` caller is a tail request (`doOpen`, its gap re-pull, `repairGap`; `loadOlder` prepends without it), which the host answers with the projection or omits it only when the full log holds no `todo/write` — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing. + +### TodoPanel: the durable list as a persistent strip + +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"/ tasks · in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. + +### TodoRow: the per-call row through the keyed toolview slot + +The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot via `ctx.slots.register` — the same seam and load-order posture as the bash sample (`inject: ['slots', 'conversation']`), but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. + +## Alternatives considered + +- **Fold todo writes into `nodes` as surface entries** — replayed windows would render every superseded list; the event is deliberately not a surface type. +- **Hardcoding the panel inside `ConversationRoot`** — the original landing spot before the input-dock slot existed; the dock is the architecture's home for always-on strips above the composer, and a hardcode bypasses the slot registry's disposal and ordering. +- **Details column for the panel** — the details slot is single-occupant and selection-driven, a different lifetime than an always-on strip. +- **Host-computed view (a todo `ToolEventView`)** — presentation belongs to the client; the wire already carries the whole snapshot in the event payload. + +## Consequences + +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel is untouched (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, and resets to empty when a tail response carries no projection. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md new file mode 100644 index 0000000000..c121ffc27e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Web todo 展示——快照副作用通道 + 两个渲染面 + +Status: implemented + +[English](2026-07-23-web-todo-display.md) | 中文 + +## Problem + +`todo_write` 把 `todo/write` 的整份列表快照追加进会话日志;TUI 渲染一块常驻的 plan 面板(自动化专用的 ACP 桥接刻意不做 todo 呈现)。Web 客户端把这个事件整个丢弃了:host mux 流本已转发每一个会话事件,但 `todo/write` 不是 surface 类型(它从不 fold 进 `ConversationSnapshot.nodes`),也没有任何副作用分支累积它——浏览器既无消费点,也无展示面。 + +## Decision + +把 `todo/write` 当作 Session 副作用消费,而非 surface 节点,并在两个面上渲染它,这两个面正对应 TUI 已经绘制的那套划分。 + +### 副作用通道,与窗口回放收敛 + +`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写)。与 partial/openCalls 不同,`rebuildDerivedFromWindow` 刻意不重置它:该值是会话级的——取自尾页 history 携带的全量 log 投影——而任意窗口未必包含最近一次写入,因此往前翻页保留它,只有窗口内或实时的写入才会覆盖。`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap`;`loadOlder` 只往前拼接、不走它),而 host 对尾页请求要么带上投影、要么仅在全量 log 没有任何 `todo/write` 时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。 + +### TodoPanel:长驻列表作为一条常驻横条 + +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 + +### TodoRow:经 keyed toolview slot 的逐调用行 + +专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.register` 注册进 keyed 的 `conversation.chat.toolview` slot——与 bash 样例同一接缝、同一载序姿态(`inject: ['slots', 'conversation']`),但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 + +## Alternatives considered + +- **把 todo 写入作为 surface 条目折叠进 `nodes`**——回放的窗口会渲染每一份已被取代的列表;该事件被刻意设计成非 surface 类型。 +- **面板硬编码进 `ConversationRoot`**——input-dock slot 出现之前的原始落点;dock 是本架构给"composer 上方常开横条"安排的家,硬编码绕开了 slot 注册表的 disposal 与定序。 +- **面板放进 details 列**——details slot 单占用且由选中驱动,生命周期不同于一条常开横条。 +- **host 计算的视图(一个 todo `ToolEventView`)**——呈现属于客户端;协议已在事件载荷里携带整份快照。 + +## Consequences + +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加 assembled keyless snapshot(`apps/web/tests/todo-display.snapshot.ts`)在构建产物客户端全图上钉住整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板未受改动(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底:history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index 3295a845f3..b8266cdd49 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -1,6 +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 -2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b -2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e +2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index a02087235a..b8e1ec1efe 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -21,10 +21,11 @@ The Host provides the following GUI wiring on the Workspace entity: | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. +`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -51,7 +52,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope. +Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -75,6 +76,8 @@ A frontend Session Intent appears as a “New session” row and temporarily cou Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. +Deleting a Workspace registration removes its group without deleting or closing any Session. Its accounted Sessions immediately join Ungrouped, including the current Session; a reload reconstructs the same result from the independent Workspace and Session baselines. + ### React and slot boundaries React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer. @@ -106,6 +109,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. - Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 8ccbf5b984..b80b75a806 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -21,10 +21,11 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。 +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -51,7 +52,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。 +Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -75,6 +76,8 @@ Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 +删除 Workspace 注册记录会移除其分组,但不会删除或关闭任何 Session。已记账的 Session(包括当前 Session)会立即进入 Ungrouped;刷新后,独立的 Workspace 与 Session 基线会重建出相同结果。 + ### React 与 slot 边界 React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。 @@ -106,6 +109,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 - UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml new file mode 100644 index 0000000000..e664ec81fb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.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 +2026-07-27-user-message-icon-actions.md: 869e7a2518a3ec927c0689a10816a410dc5f0862 +2026-07-27-user-message-icon-actions.zh.md: 353e5ac765bb2fbab9932449cf2247768a1f412f diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md new file mode 100644 index 0000000000..869e7a2518 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md @@ -0,0 +1,27 @@ +# Agent Note: User-message IconActions under the bubble + +Status: implemented + +English | [中文](2026-07-27-user-message-icon-actions.zh.md) + +## Problem + +The chat user bubble had no under-bubble action chrome. The Harness design (figma `User_Bubble/message_container`) shows three IconActions — copy, branch in new chat, and edit — right-aligned under the bubble, matching the product action-bar pattern used elsewhere. + +## Decision + +`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. Actions stay visible by default; `@media (hover: hover)` hides them until the row is hovered or focus-within, so touch / `hover: none` devices keep discoverable controls (opacity alone still hit-tests). + +Copy writes the bubble's joined text blocks to the clipboard (`navigator.clipboard.writeText`, with an `execCommand` fallback). Branch and edit are present chrome with no handlers yet — they reserve the design seats without inventing session-fork or edit-resubmit behavior. + +Steering bubbles keep the badge-only form and do not show these actions. + +## Alternatives considered + +**Wire branch/edit to real session fork and draft-edit now.** Rejected for this change: those product flows are not specified; shipping inert buttons matches the requested scope and avoids half-built mutation paths. + +**Always hide with `opacity: 0` outside hover.** Rejected for touch: without `@media (hover: hover)`, idle opacity still hit-tests while looking empty. Hover-capable pointers keep the fade; others keep the actions visible. + +## Consequences + +User messages expose copy immediately; branch/edit remain clickable stubs until a later decision owns their behavior. Tests pin the three buttons, copy payload, and steering exclusion. diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md new file mode 100644 index 0000000000..353e5ac765 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 用户消息气泡下方的 IconActions + +Status: implemented + +[English](2026-07-27-user-message-icon-actions.md) | 中文 + +## 问题 + +聊天用户气泡下方没有操作栏。Harness 设计稿(figma `User_Bubble/message_container`)在气泡下方右对齐展示三个 IconActions——复制、在新对话中分支、编辑——与产品其他位置使用的操作栏模式一致。 + +## 决策 + +仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。操作默认保持可见;`@media (hover: hover)` 下在悬停或 focus-within 前隐藏,以便触摸/`hover: none` 设备仍能发现控件(仅靠 opacity 仍会命中测试)。 + +复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。 + +steering(中途引导)气泡保持仅徽章形态,不展示这些操作。 + +## 考虑过的替代方案 + +**现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。 + +**在悬停外始终以 `opacity: 0` 隐藏。**因触摸不予采纳:若无 `@media (hover: hover)`,空闲 opacity 看起来空白但仍会命中测试。具备悬停能力的指针保留淡入;其他设备保持操作可见。 + +## 后果 + +用户消息立即可用复制;分支/编辑仍为可点击的占位,直至后续决策明确其行为。测试钉死三个按钮、复制载荷,以及对 steering 的排除。 diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml new file mode 100644 index 0000000000..d576fb10e5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.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/feature/2026-07-27-workspace-registration-deletion.md +2026-07-27-workspace-registration-deletion.md: 8168b0832ca39e6023f6981815ffe758b5695361 +2026-07-27-workspace-registration-deletion.zh.md: b0df6982ac81426a5b0ce2f0e2b0e744212e3f5b diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md new file mode 100644 index 0000000000..8168b0832c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -0,0 +1,59 @@ +# Agent Note: Workspace Registration Deletion + +Status: implemented + +English | [中文](2026-07-27-workspace-registration-deletion.zh.md) + +## Problem + +A Workspace registers an existing code directory so the GUI can name it and order its Sessions. That record has no reliable provenance proving that Harness created or owns the directory, and the Session log is an independent persistence object. Treating the row's Delete action as recursive source deletion or Session deletion would destroy data outside the record's ownership boundary. + +The existing visual-only menu row also left deletion semantics undefined across durable order, the Workspace table, Host streams, concurrent browser tabs, reconnect baselines, and a list request racing the mutation. + +## Decision + +`ctx.workspace.delete(id)` deletes only the Workspace registration: its id leaves durable `workspaceIds`, its `workspaces` table row and entity-cache entry disappear, and its ordered `sessionIds` account disappears with that row. It never calls filesystem removal or `SessionPersistence`; the directory, every user file, every live Session, and every persisted Session log remain. Because sidebar grouping is the complement of all surviving Workspace accounts, those Sessions immediately appear under Ungrouped, including the current Session. + +Unknown ids return `false` at the domain seam. `workspace.delete({ workspaceId })` maps that distinction to `workspace-not-found`; success returns `{ deleted: true }`. `workspace.list` remains the reconnect baseline. + +## Durable commit and publication + +Registry operations serialize create and delete. Deletion first writes the Workspace order without the id, then removes the entity from the cache, then deletes the table row. The table deletion is the notification commit point: the package invariant accepts it only after the cache stopped publishing the entity, and the Host emits `host/workspace-removed` only from that committed deletion. A table-write failure restores the cache and prior durable order; no removal frame is published. + +The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection. + +Create and delete write a durable `pendingMutation` before their record/order pair can diverge. Startup completes only the named create or delete and clears the marker; it never infers crash provenance from an orphan row alone. Unmarked order/table divergence therefore retains the registry's fail-loud corruption behavior. A deletion whose table write committed but marker cleanup failed still reports success—the requested state and removal frame are already committed—and the next startup clears that marker idempotently. + +## Client convergence + +`WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. + +The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next create gesture cannot observe one stale list frame. During create, duplicate-name validation is suppressed while the request is pending because the committed `host/workspace-changed` frame may publish the newly created Workspace before its unary response; after failure returns the form to editing, validation uses the latest list again. + +## Confirmation interaction + +The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete. + +The menu, Modal, and buttons retain their existing structure and design tokens. Session deletion remains visual-only and outside this decision. + +## Alternatives considered + +**Cascade-delete Sessions.** Rejected because Workspace registration does not own Session persistence and the product requirement is to preserve histories under Ungrouped. Session deletion needs its own lifecycle, running checks, descendant semantics, and explicit UI. + +**Move the folder to Trash.** Rejected because the record cannot prove directory ownership. A future destructive filesystem action must be separately named, separately confirmed, and enforce explicit safety boundaries. + +**Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure. + +**Delete every unreferenced row at startup.** Rejected because the same shape can come from unexplained order corruption; silently discarding it could lose Workspace metadata and Session accounting. Recovery requires the explicit pending marker written by the owning mutation. + +**Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path. + +## Verification + +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, pending-state duplicate suppression, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory. + +The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. + +## Consequences + +Deleting a Workspace is intentionally reversible by registering the same directory again with a fresh id, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md new file mode 100644 index 0000000000..b0df6982ac --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -0,0 +1,59 @@ +# Agent Note(agent 决策记录):删除 Workspace 注册记录 + +Status: implemented + +[English](2026-07-27-workspace-registration-deletion.md) | 中文 + +## Problem + +Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其会话排序。该记录没有可靠的来源信息来证明 Harness 创建或拥有该目录,会话日志也是独立的持久化对象。若将行内 Delete 操作视为递归删除源码或删除会话,就会破坏该记录所有权边界之外的数据。 + +现有菜单行仅提供视觉效果,因此持久顺序、Workspace 表、Host 流、并发浏览器标签页、重连基线,以及列表请求与变更并发时的删除语义也没有定义。 + +## Decision + +`ctx.workspace.delete(id)` 只删除 Workspace 注册记录:其 id 会从持久 `workspaceIds` 中移除,`workspaces` 表行与实体缓存条目会消失,有序 `sessionIds` 账本也随该行一并消失。它绝不调用文件系统移除操作或 `SessionPersistence`;目录、所有用户文件、所有实时会话和所有持久化会话日志都会保留。侧边栏分组是所有存续 Workspace 账本的补集,因此这些会话(包括当前会话)会立即出现在 Ungrouped 下。 + +未知 id 在 domain seam 返回 `false`。`workspace.delete({ workspaceId })` 将该结果映射为 `workspace-not-found`;成功时返回 `{ deleted: true }`。`workspace.list` 仍是重连基线。 + +## 持久提交与发布 + +注册表操作会串行执行创建与删除。删除时先写入移除该 id 后的 Workspace 顺序,再从缓存中移除实体,最后删除表行。表删除是通知提交点:只有缓存停止发布该实体后,包不变量才接受该删除;Host 也只根据这次已提交的删除发出 `host/workspace-removed`。表写入失败时,系统会恢复缓存和此前的持久顺序,且不会发布移除帧。 + +Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。 + +Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。 + +## 客户端收敛 + +`WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 + +删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次创建操作不会读到一帧陈旧列表。创建请求进行中会暂停重复名称校验,因为已提交的 `host/workspace-changed` 帧可能先于一元响应发布刚创建的 Workspace;如果请求失败并让表单回到可编辑状态,系统会重新使用最新列表执行校验。 + +## 确认交互 + +现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。 + +菜单、`Modal` 和按钮保留现有结构与设计 token。会话删除仍仅提供视觉效果,不在本决策范围内。 + +## Alternatives considered + +**级联删除会话。** 不予采纳,因为 Workspace 注册记录不拥有会话持久化,且产品需求是将历史记录保留在 Ungrouped 下。会话删除需要自己的生命周期、运行状态检查、后代对象的处理语义和明确 UI。 + +**将文件夹移到废纸篓。** 不予采纳,因为该记录无法证明目录所有权。未来的破坏性文件系统操作必须使用单独名称、单独确认,并实施明确的安全边界。 + +**先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。 + +**启动时删除所有未引用表行。** 不予采纳,因为来源不明的顺序损坏也会呈现相同形状;静默丢弃可能损失 Workspace 元数据和 Session 账本。恢复必须依赖拥有该变更的操作预先写入的明确待处理标记。 + +**成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。 + +## Verification + +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、待处理状态下抑制重复提交、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。 + +组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 + +## Consequences + +删除 Workspace 后仍可使用新 id 重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 17edb300cc..51073f506e 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -1,6 +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 -2026-07-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +2026-07-21-serial-cross-platform-ci-reference.md: 9ecd7906d2f28461a8a6f3f2fe485580214b39f3 +2026-07-21-serial-cross-platform-ci-reference.zh.md: ed927012c5b0ba9d43444a129d10d966f5e7d923 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 5433d2c518..9ecd7906d2 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -14,17 +14,17 @@ Reviewers also need a direct answer to a simpler question: what happens when the ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. -Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. +Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures. The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 041d53d13e..ed927012c5 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -14,17 +14,17 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 -每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 +每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。 macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 3d8e6fc395..68b4098d4f 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -1,6 +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 -2026-07-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +2026-07-22-evidence-based-larger-hosted-runners.md: 67fc7ded5cffc6a219665f135a4c9e1cc4752691 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 71c5c067361b57fab5aae9e9ffa3850a30609db3 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index fe11e69295..67fc7ded5c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,6 +52,8 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. + ## Alternatives considered **Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 4787928453..71c5c06736 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,6 +52,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 + ## 曾考虑的替代方案 **保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 05147cd54a..66131cfe0c 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -1,6 +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 -2026-07-23-portable-required-pull-request-ci.md: d1002c7d9db7cd8bbed3bdfda8a773a4b124bf16 -2026-07-23-portable-required-pull-request-ci.zh.md: fedfc6b9c982ace5ece430c52db23c22ec5119d4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45 +2026-07-23-portable-required-pull-request-ci.zh.md: cf0af769f9e740a2c9285caf4be05023371578d9 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index d1002c7d9d..1a6939e838 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` with single-worker bounds, keeping the complete Windows contract independent of enterprise Windows allocation. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. -The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) ## Consequences -Ordinary pull requests spend enterprise capacity on the Linux critical path while standard Windows trades longer runtime for independent allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. +Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index fedfc6b9c9..cf0af769f9 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上运行,并采用单工作线程上限,使完整的 Windows 契约不依赖企业级 Windows 运行器分配。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断表面([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单归 master 串行参考流程所有。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而标准托管 Windows 作业则以更长的运行时间换取不依赖企业池的运行器分配。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 8bc3f87435..e8fdb3f515 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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 -2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 -2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e +2026-07-25-semantic-pr-label-taxonomy.md: 3217b405e968d4d2c1eba1f1a5a08008b18ba514 +2026-07-25-semantic-pr-label-taxonomy.zh.md: 4cc603daa52bc9e6b0a85e33086a559a21dcc621 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index 61b7a829b8..3217b405e9 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -30,13 +30,13 @@ Areas record semantic repository domains rather than temporary initiatives, owne ### Current areas -The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The 46 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. | Group | Areas | |---|---| | Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Capabilities | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index cc0c5e7a89..4cc603daa5 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -30,13 +30,13 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 ### 当前领域 -当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +当前的 46 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 | 分组 | 领域 | |---|---| | agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 能力 | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml new file mode 100644 index 0000000000..a7b9e827fa --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md +2026-07-26-ci-failover-runbook.md: 4e4f8ea7fc60cf76fd8308147bbf7cc0bac74798 +2026-07-26-ci-failover-runbook.zh.md: bb7e43fe55c9cced51f042de6503978ec9349d6b diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md new file mode 100644 index 0000000000..4e4f8ea7fc --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -0,0 +1,50 @@ +# Agent Note: CI failover runbook — hosted pools → in-house pool + +Status: implemented + +English | [中文](2026-07-26-ci-failover-runbook.zh.md) + +## Problem + +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`, and only the Windows leg has no in-house substitute at all — during the 2026-07-27 outage the standard pools recovered first, which is the ordering this design bets on. An outage therefore needs a switch any responder with repository write access can throw without merging anything. + +## Decision + +Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. + +### What the in-house pool is + +`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. + +### Switch (any repository writer, ~1 minute, no merge) + +1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. +2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. +3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). + +#**Dependabot exception.** All four selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VM. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. + +**Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so the switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner group admits all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VM by pushing a branch workflow. The boundary against untrusted code is repository membership; the variable only routes work for members. + +## Capacity during failover + +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance. + + +### Switch back + +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Remove any extra instances that were registered during the incident. + +### Trust boundary + +The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the four failover jobs are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access. + +## Alternatives considered + +**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is writer-manageable state that takes effect on re-run without a merge. + +**Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby. + +## Consequences + +Recovering from a hosted-pool outage is a single variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md new file mode 100644 index 0000000000..bb7e43fe55 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -0,0 +1,50 @@ +# Agent Note: CI 故障切换手册 — 托管池 → 自有池 + +Status: implemented + +[English](2026-07-26-ci-failover-runbook.md) | 中文 + +## 问题 + +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`、`windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`,且只有 Windows 这条腿完全没有自有替代——2026-07-27 的故障中标准池率先恢复,本设计押注的正是这一顺序。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 + +## 决策 + +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 + +### 自有池是什么 + +`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 + +### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) + +1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 +2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 + +#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖方提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 + +**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成越权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 + +## 切换期间的容量 + +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。 + + +### 切回 + +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若故障期间追加注册过实例,将其移除。 + +### 信任边界 + +该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——四个故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。 + +## 曾考虑的替代方案 + +**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是写者可管理的状态,重跑即生效,无需合并。 + +**让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。 + +## 后果 + +从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml new file mode 100644 index 0000000000..7ad4d7bee7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 006360cedfd4d1e2c2b67ede98062a375e316f47 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d511e7a2b89788fe8addd2cc47634742c7a87fe4 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md new file mode 100644 index 0000000000..006360cedf --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -0,0 +1,34 @@ +# Agent Note: Provision CI pnpm via pnpm/action-setup + +Status: implemented + +English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) + +## Problem + +Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned it with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml`. The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break. + +## Decision + +`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: + +- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive. + +## Alternatives considered + +- **Keep the hand-rolled steps.** They worked, but they were drifting copies of setup boilerplate, and the corepack dependency was a known future break. +- **Convert the enterprise jobs' caching to `cache: pnpm`.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. +- **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. +- **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected on review follow-up: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit. +- **Rely on the runner image's Yarn.** Rejected: the hosted image exposes Yarn 1.22 after Corepack is removed, while the generated-project e2e requires Yarn 2 or newer. A locked root dev dependency makes that coverage independent of runner image contents. +- **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor. + +## Consequences + +- The corepack dependency is gone from CI entirely; pnpm arrives via the pnpm team's official action everywhere, and the version pin stays single-sourced in `package.json`'s `packageManager` field. +- The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version. +- The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. +- `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. +- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md new file mode 100644 index 0000000000..d511e7a2b8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 经由 pnpm/action-setup 提供 CI 的 pnpm + +Status: implemented + +[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文 + +## 问题 + +除 `landlock-run.yml` 外,每个安装 pnpm 的工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。 + +## 决策 + +`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态: + +- **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已能直接提供热安装。 +- **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。 + +## 曾考虑的替代方案 + +- **保留手写步骤。** 它们能用,但那是会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 +- **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 +- **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 +- **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 +- **依赖 runner 镜像自带的 Yarn。** 否决:Corepack 移除后,托管镜像提供的是 Yarn 1.22,而 generated-project e2e 要求 Yarn 2 或更高版本。锁定版本的根开发依赖让该项覆盖率不再受 runner 镜像内容影响。 +- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 + +## 后果 + +- corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。 +- generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 +- 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 +- `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 +- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml new file mode 100644 index 0000000000..b8d12ccea3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.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/process/2026-07-27-wine-windows-gates-experiment.md +2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc +2026-07-27-wine-windows-gates-experiment.zh.md: f30e09ca7411ef83d02faf012ce54d6c6c65dff1 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md new file mode 100644 index 0000000000..640c8e455b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -0,0 +1,47 @@ +# Agent Note: Wine-run Windows blocking gates on Linux runners + +Status: implemented + +English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md) + +## Problem + +The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — and it ran on hosted `windows-2025`, the slowest job in the required matrix: 7–9 minutes against 1.5–2.5 for the Linux jobs, so the Windows VM's boot, setup, and filesystem costs dominated every pull request's critical path. + +The question the experiment answered: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all? + +## Decision + +The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push. + +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). + +The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. + +The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The local gate is a diagnosis tool, not a routine check: run it only when investigating a known Windows-related failure; CI owns the everyday win32 signal, and [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) never selects it. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. + +Five environment constraints shape CI and local execution, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the caller's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); macOS Wine also exposes hoisted workspace links as ordinary directories, so the client test aggregate includes every package-local CSS module declaration instead of relying on project-reference realpaths; and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). + +## Measured results + +Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubuntu-latest`: 2m46s end-to-end — setup and cache restores ~17s, concurrent install+provision 33s, concurrent gates 110s — against 1.5–2.5 minutes for the Linux CI jobs and 7–9 minutes for the replaced `windows-2025` job. A cold-cache run pays roughly one extra minute. An 8-core benchmark leg was defined during the experiment but never left the restricted `dsh-ubuntu-*` pool's queue; the standard-runner number met the target, so no larger box is used. + +## Alternatives considered + +**Keep the hosted `windows-2025` pull-request job (status quo).** Nothing wrong with its signal, only its latency: 7–9 minutes for two build commands, the slowest required job in the matrix. It survives as the master serial reference, where completeness matters more than latency. + +**A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget. + +**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. + +**Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane. + +**Windows containers.** Not possible: Windows containers require a Windows host kernel; a hosted Linux runner cannot run them. + +**Dropping the Windows lane.** Rejected — win32 is a first-class product target: the koffi-backed DACL and durable-namespace modules, ConPTY-based PTY sessions, and Windows path policy all ship in `packages/`. + +## Consequences + +Every pull request's Windows verdict now arrives in Linux-job time on free standard capacity, and no Windows VM allocation sits on the pull-request critical path; `all checks passed` consumes the same `windows` job id it always did. + +What the trade costs: Wine reimplements Win32 over a case-sensitive ext4 — NTFS case-insensitivity, real DACLs, ConPTY, and crash-durability semantics are not proved here, and the observational portability inventory (duplication, publint, node-next types, built-package invariants on win32) no longer runs on pull requests at all. The master `serial-windows` reference owns all of that: a Wine-green pull request can still fail the native-kernel master run, and that failure mode is accepted as post-merge. The lane also inherits Wine-specific divergences as permanent job structure — file-routed stdio, the host-side `vue` link, the hoisted layout — so a future toolchain change that depends on isolated-layout semantics or in-process symlink creation will surface here first as a Wine failure rather than a product failure, and triage must classify it as such. If Wine reds ever recur without product cause, the recorded fallback is reverting the `windows` job to the pre-Wine `windows-2025` definition preserved in git history. diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md new file mode 100644 index 0000000000..f30e09ca74 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁 + +Status: implemented + +[English](2026-07-27-wine-windows-gates-experiment.md) | 中文 + +## 问题 + +Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表面——workspace 构建与生产站点——它此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:7–9 分钟,对照 Linux 作业的 1.5–2.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。 + +实验回答的问题是:一台普通 Linux runner 能否以 Linux 墙钟为阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM? + +## 决策 + +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 + +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其"Windows pnpm 安装依赖树"的目标(安装契约在此仍由 Linux 侧验证)。 + +该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 + +门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 + +五条环境约束塑造了 CI 与本地执行,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到调用方的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);macOS Wine 也会把 hoisted workspace 链接暴露为普通目录,因此 client 测试聚合会纳入每个包自己的 CSS 模块声明,而不依赖 project-reference realpath;Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 + +## 实测结果 + +2026-07-27 实测,热缓存,pull request 触发,标准 2 核 `ubuntu-latest`:端到端 2 分 46 秒——准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒——对照 Linux CI 作业的 1.5–2.5 分钟与被替换的 `windows-2025` 作业的 7–9 分钟。冷缓存约多付一分钟。实验期间定义过 8 核基准腿,但它从未离开受限 `dsh-ubuntu-*` 池的队列;标准 runner 的数字已达标,故不使用更大的机器。 + +## 考虑过的替代方案 + +**保留托管 `windows-2025` 的 pull request 作业(现状)。** 其信号没有问题,问题只在延迟:为两条构建命令花 7–9 分钟,是必需矩阵中最慢的作业。它作为 master 串行参照存续——在那里完整性比延迟更重要。 + +**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可晋升。 + +**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道用掉这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 + +**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 + +**Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。 + +**砍掉 Windows 通道。** 已否决——win32 是一等产品目标:基于 koffi 的 DACL 与持久命名空间模块、基于 ConPTY 的 PTY 会话、以及 Windows 路径策略都随 `packages/` 交付。 + +## 结果 + +每个 pull request 的 Windows 裁决现在以 Linux 作业的时间在免费标准容量上到达,pull request 关键路径上不再有任何 Windows VM 分配;`all checks passed` 消费的仍是原来的 `windows` 作业 id。 + +这笔交易的代价:Wine 在大小写敏感的 ext4 之上重实现 Win32——NTFS 大小写不敏感、真实 DACL、ConPTY 与崩溃持久性语义在此都未被证明,且观察性可移植性清单(duplication、publint、node-next 类型、win32 上的构建包不变量)完全不再于 pull request 上运行。master 的 `serial-windows` 参照拥有这一切:Wine 绿灯的 pull request 仍可能在原生内核的 master 运行上失败,该失败模式被接受为合并后处理。该通道还把 Wine 特有的分歧继承为永久的作业结构——文件中转的 stdio、宿主侧的 `vue` 链接、hoisted 布局——因此未来依赖 isolated 布局语义或进程内符号链接创建的工具链变更会先在这里以 Wine 失败而非产品失败的形式浮现,分诊必须如此归类。若 Wine 红灯在无产品原因的情况下反复出现,记录在案的退路是把 `windows` 作业还原为 git 历史中保存的 Wine 之前的 `windows-2025` 定义。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml similarity index 59% rename from .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml index 0219885a7c..8a52737dfc 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.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 -2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 0bf32e01ea407e2718f8ec39ca962587a37df9cc -2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: ba9f157a61ffdc415acf9b2a61857026fc2c8bf1 +2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 6370c8f92eff7296327e941e698ec4f733100bb2 +2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: 3587c92e0e9655d8b38d24d184c1c68c44b131d4 diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md new file mode 100644 index 0000000000..6370c8f92e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md @@ -0,0 +1,33 @@ +# Agent Note: Consolidate gate scripts on already-present deps and builtins + +Status: implemented + +English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md) + +## Problem + +The `scripts/` gates mostly used the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-rolled what a sibling gate already did with an existing dependency or builtin: + +- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) were two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracted fences by visiting mdast `code` nodes — and `markdownProseLines` in `scripts/markdown.ts` itself parsed to mdast but then hand-tracked fence state with a second regex. The regex scanners only recognized backtick fences at column 0, so they silently disagreed with the mdast-based gates on tilde and indented fences. +- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) stepped argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already used the `node:util` `parseArgs` builtin. +- **Hand-rolled directory walks.** Five sites re-derived nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template. + +No new dependency was needed anywhere; every replacement is an existing devDep or a Node builtin. + +## Decision + +- A shared mdast fence helper, `markdownFences` in `scripts/markdown.ts`, visits `code` nodes for the language, full info string, body, and 1-based opening-fence line; `doc-typecheck.ts` and `verify-type-equiv.ts` extract fences through it. `md-fences.ts` and the duplicated `extractEquivBlocks` scanner are deleted, and `markdownProseLines` derives fenced lines from the parsed `code` nodes' positions instead of a second regex. +- Both CLIs parse argv via `parseArgs`; unknown options and missing values still fail loud, with `parseArgs`'s own error text instead of the bespoke usage strings. +- The five straggler walks use `globSync`. The walks in `check-workspace-constraints.ts` and `clean.ts` stay: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report. + +## Alternatives considered + +- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these were stragglers, not a gap. +- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer. +- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation was a latent inconsistency between sibling gates. + +## Consequences + +- One fence parser: every markdown gate now classifies fences through mdast, so tilde, indented, and 4-backtick container fences behave identically everywhere. The docs tree contained no fence shape the regex scanners mishandled, so gate results are unchanged on the tree that landed the swap: `pnpm run doc-sync` and each rewritten gate ran before and after with byte-identical output (`doc-typecheck` block/opt-out counts, `verify-type-equiv` match counts, `publint`, `verify-built-package-invariants`, `verify-runtime-closure`, `verify-package-paths`, `verify-client-domain-graph`, and both package-README prose gates). +- `verify-type-equiv` still rejects an unterminated type-equivalence fence: mdast silently closes an unterminated block at end-of-file (its comparisons could then pass), so the shared helper reports whether a closing delimiter exists and the gate errors on an unclosed block, preserving the removed scanner's rejection. The `doc-typecheck` scanner never had that error path. +- `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin, accepted in exchange for deleting the two bespoke parsers. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the replaced parsers.) diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md new file mode 100644 index 0000000000..3587c92e0e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 把门禁脚本统一到已有依赖与内置模块上 + +Status: implemented + +[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文 + +## 问题 + +`scripts/` 下的门禁大多本已在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本曾手写同类门禁早已用既有依赖或内置模块完成的事情: + +- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)曾是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 早已通过访问 mdast `code` 节点来提取代码围栏;`scripts/markdown.ts` 自己的 `markdownProseLines` 也曾先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 +- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)曾手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)早已在使用 `node:util` 的内置 `parseArgs`。 +- **手写的目录遍历。**五处代码曾各自重写 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 + +所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 + +## 决策 + +- `scripts/markdown.ts` 中的共享 mdast 围栏辅助函数 `markdownFences` 访问 `code` 节点,读取语言、完整 info string、块体以及以 1 起始的开围栏行号;`doc-typecheck.ts` 和 `verify-type-equiv.ts` 通过它提取代码围栏。`md-fences.ts` 和重复的 `extractEquivBlocks` 扫描器已删除,`markdownProseLines` 也改为从解析出的 `code` 节点位置推导围栏内的行,而不再用第二个正则。 +- 两个 CLI 都改用 `parseArgs` 解析 argv;未知选项和缺失取值仍然大声失败,只是错误文案换成了 `parseArgs` 自带的文本,而非原先手写的用法字符串。 +- 那五处掉队的目录遍历改用 `globSync`。`check-workspace-constraints.ts` 和 `clean.ts` 中的遍历保留:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。 + +## 曾考虑的替代方案 + +- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。 +- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。 +- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。 + +## 后果 + +- 只剩一个围栏解析器:所有 markdown 门禁现在都经由 mdast 归类代码围栏,因此波浪线围栏、缩进围栏和四反引号容器围栏在各处的行为完全一致。文档树中不存在正则扫描器处理有误的围栏形态,所以在落地这次替换的代码树上门禁结果不变:`pnpm run doc-sync` 及每个被改写的门禁在改动前后各跑一遍,输出逐字节相同(`doc-typecheck` 的块数/opt-out 计数、`verify-type-equiv` 的匹配计数、`publint`、`verify-built-package-invariants`、`verify-runtime-closure`、`verify-package-paths`、`verify-client-domain-graph`,以及两个包 README 散文门禁)。 +- `verify-type-equiv` 仍然拒绝未闭合的类型等价围栏:mdast 会在文件末尾静默闭合未闭合的代码块(其比较随后可能通过),因此共享辅助函数会报告闭合定界符是否存在,门禁在块未闭合时报错,保留了被删扫描器的这条拒绝路径。`doc-typecheck` 的扫描器本来就没有这条错误路径。 +- `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例,作为删除两份手写解析器的交换被接受。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与被替换的解析器行为一致。) diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml similarity index 60% rename from .agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml index c486815180..860af008f4 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.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 -2026-07-26-eventsource-parser-for-deepseek-sse.md: 8a93b7f6c7aa0d428f25e87c44e1d29e884ecc81 -2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: b16109d9458f487c7e463cf02e6b2d22fbbde015 +2026-07-26-eventsource-parser-for-deepseek-sse.md: e7835bc738b3dec5aefd6011848525f6604e852e +2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 933b993d479026d8f2bd2dc3173abd9e60823806 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md new file mode 100644 index 0000000000..e7835bc738 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md @@ -0,0 +1,28 @@ +# Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser + +Status: implemented + +English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md) + +## Problem + +`packages/llm/llm-deepseek/src/sse.ts` hand-implemented Server-Sent Events parsing: a streaming `TextDecoder`, event-block splitting on `\r?\n\r?\n`, `data:` payload extraction and joining, comment/field skipping, the `[DONE]` sentinel, a `STREAM_CLOSED` error on EOF without it, and a flush of a final unterminated event block. The file was ~67 lines with ~108 lines of dedicated tests (`tests/sse.spec.ts`) re-proving SSE spec behavior — UTF-8 split across chunks, CRLF handling, multi-`data:` joining, no-space-after-colon — that a maintained parser already guarantees. Its only consumer is `adapter.ts` (`yield* translate(parseSse(response.body))`). + +This is exactly the surface `eventsource-parser` owns: the de-facto standard SSE parser (it underlies the Vercel AI SDK and the MCP SDK), zero-dependency, actively maintained, and already present in this repo's lockfile transitively via `@modelcontextprotocol/sdk` — so adopting it directly adds no new supply-chain surface in practice. + +## Decision + +`sse.ts` delegates SSE framing to `EventSourceParserStream` from `eventsource-parser/stream`: `parseSse` pipes the response body through `new TextDecoderStream()` then `new EventSourceParserStream()` and keeps only the DeepSeek protocol shim — yield each event's `data`, terminate on `[DONE]`, and throw `LlmError('STREAM_CLOSED')` when the stream ends without the sentinel. All required builtins (`TextDecoderStream`, `pipeThrough`, async-iterable `ReadableStream`) exist at the Node ^22.19 engine floor. The spec-conformance tests are gone; `tests/sse.spec.ts` pins only the `[DONE]`/`STREAM_CLOSED`/EOF contract. `eventsource-parser` is `llm-deepseek`'s second runtime dependency after schemastery. The [twin-adapters note](../architecture/2026-06-13-twin-llm-adapters.md) and the `dsh-llm` JSDoc that branded this adapter "hand-rolled fetch + SSE parsing" now describe it as direct fetch with library-framed SSE. + +The library also strips a leading BOM (the hand-rolled parser would fail to match `data:` after one) and offers `maxBufferSize` hardening the hand-rolled parser lacked. + +## Alternatives considered + +- **Keep the hand-rolled parser.** Defensible under the [twin-adapters decision](../architecture/2026-06-13-twin-llm-adapters.md): the adapter is deliberately the hand-rolled design-verification twin of the pi-ai adapter. But that note's load-bearing distinction is owning the fetch/translate internals versus delegating to a full provider SDK; a ~700-byte SSE micro-parser is transport plumbing, not the design under verification. The twin-adapters note now states that reading explicitly. +- **`createParser({onEvent})` callback API instead of the stream.** Works fed by a manual `TextDecoder` loop, but the `pipeThrough` composition deletes more of the hand-rolled code. + +## Consequences + +- The remaining shim only encodes the DeepSeek `[DONE]`/`STREAM_CLOSED` protocol; SSE framing edge cases are eventsource-parser's contract and are no longer re-proven here. +- One deliberate robustness deviation is dropped: the hand-rolled parser flushed a final event block that lacked its terminating blank line, so a trailing `data: [DONE]` without `\n\n` still yielded DONE. eventsource-parser is spec-strict and only dispatches on the blank line, so that shape is now `STREAM_CLOSED`. Real providers and `dsh-llm-mock-server` always terminate events properly — the flush was a robustness nicety, not an observed provider shape — and `tests/sse.spec.ts` pins the new truncation verdict for that tail. +- The documented "hand-rolled" identity of the twin adapter narrows to the fetch/translate internals; the twin-adapters note was updated in the same change rather than leaving the claim stale. diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md new file mode 100644 index 0000000000..933b993d47 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md @@ -0,0 +1,28 @@ +# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器 + +Status: implemented + +[English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文 + +## 问题 + +`packages/llm/llm-deepseek/src/sse.ts` 曾手写实现 SSE(Server-Sent Events)解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。 + +这恰好是 `eventsource-parser` 负责的接口面:事实标准的 SSE 解析器(Vercel AI SDK 和 MCP SDK 都构建在它之上),零依赖,持续维护,并且已通过 `@modelcontextprotocol/sdk` 作为传递依赖出现在本仓库的 lockfile 中——因此直接采用它实际上不增加新的供应链接触面。 + +## 决策 + +`sse.ts` 将 SSE 分帧委托给 `eventsource-parser/stream` 的 `EventSourceParserStream`:`parseSse` 把响应 body 依次管道接入 `new TextDecoderStream()` 和 `new EventSourceParserStream()`,只保留 DeepSeek 协议垫层——逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。规范符合性测试已删除;`tests/sse.spec.ts` 只固定 `[DONE]`/`STREAM_CLOSED`/EOF 契约。`eventsource-parser` 是 `llm-deepseek` 继 schemastery 之后的第二个运行时依赖。曾把该适配器标为「手写 fetch + SSE 解析」的[孪生适配器 Agent Note(agent 决策记录)](../architecture/2026-06-13-twin-llm-adapters.md)与 `dsh-llm` JSDoc,现在将其描述为直接 fetch 加库分帧的 SSE。 + +该库还会剥离开头的 BOM(手写解析器在 BOM 之后会无法匹配 `data:`),并提供手写解析器缺少的 `maxBufferSize` 加固能力。 + +## 曾考虑的替代方案 + +- **保留手写解析器。** 依据[孪生适配器决策](../architecture/2026-06-13-twin-llm-adapters.md),这一选择有辩护余地:该适配器有意作为 pi-ai 适配器的手写设计验证孪生体。但那份 Agent Note 起支撑作用的区分在于「自行持有 fetch/translate 内部实现」与「委托给完整的提供方 SDK」;一个约 700 字节的 SSE 微型解析器属于传输层管道,不是被验证的设计本身。孪生适配器 Agent Note 现已明确写出这一解读。 +- **改用 `createParser({onEvent})` 回调 API 而非流。** 配合手动的 `TextDecoder` 循环可以工作,但 `pipeThrough` 组合方式能删除更多手写代码。 + +## 后果 + +- 剩下的垫层只编码 DeepSeek 的 `[DONE]`/`STREAM_CLOSED` 协议;SSE 分帧边界情形属于 eventsource-parser 的契约,不再在这里重复验证。 +- 放弃了一处有意为之的健壮性偏离:手写解析器会 flush 缺少终结空行的最后一个事件块,因此末尾的 `data: [DONE]` 即使没有 `\n\n` 也仍产出 DONE。eventsource-parser 严格遵循规范,只在空行处分发事件,所以这种形态现在是 `STREAM_CLOSED`。真实提供方和 `dsh-llm-mock-server` 总是正确终结事件——该 flush 只是健壮性上的锦上添花,并非实际观测到的提供方形态——`tests/sse.spec.ts` 固定了对该尾部的新截断判定。 +- 孪生适配器有文档记录的「手写」身份收窄到 fetch/translate 内部实现;孪生适配器 Agent Note 在同一次变更中更新,而不是让声明陈旧下去。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml new file mode 100644 index 0000000000..41f59f644f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.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/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +2026-07-26-turndown-for-tool-web-html-markdown.md: 0e387021e3d3be3011cc0d64d37864b30aec4fdf +2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 44e7d08c1db40a1203e8cda955b521774335e774 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md new file mode 100644 index 0000000000..0e387021e3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md @@ -0,0 +1,37 @@ +# Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown + +Status: implemented + +English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) + +## Problem + +`dsh-tool-web`'s `src/html.ts` (~86 lines, ~40 lines of dedicated tests; deleted by this change) converted fetched HTML to markdown with regexes: strip script/style/noscript/comments, convert ``/``/`
  • `, decode numeric entities plus a 12-entry named-entity table, collapse whitespace. The module's own JSDoc said "A richer converter can replace it without changing the seam or tool schema", and the README's Known Limitations documented it as "a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost." The [web capability seam note](../architecture/2026-06-24-web-capability-seam.md) assigns HTML→markdown to this package as presentation, so the swap point was exactly here. The converter's output is model-visible on every fetched HTML page; no keyless snapshot exercised `web_fetch`, so no expected outputs pinned it. + +## Decision + +`packages/web/tool-web/src/fetch.ts` owns a module-level [`turndown`](https://github.com/mixmark-io/turndown) instance (`headingStyle: 'atx'`, `codeBlockStyle: 'fenced'`, `bulletListMarker: '-'` — fixed model-facing presentation, not deployment tunables) with `@joplin/turndown-plugin-gfm`'s composite `gfm` plugin for tables/strikethrough and `remove(['script', 'style', 'noscript'])` replacing the old wholesale drops. `formatFetchOutput` limits both the source prefix converted synchronously and the complete rendered output with `fetchMaxOutputChars` (default 200,000), so a custom provider cannot make conversion work unbounded before the output cap applies. The HTML arm then guards conversion twice: a conservative linear lexical pass treats comment contents conservatively, skips raw-text bodies, honors quoted tag text, and passes a body through raw when its stack crosses 512 levels; a try/catch also falls back to raw HTML when turndown rejects markup the guard cannot model. The GFM cell rule is overridden to ignore `colspan`, which Markdown cannot represent, rather than letting an untrusted numeric attribute synthesize arbitrary empty cells. `html.ts` and its conversion tests are deleted; the source/output bounds, fallback, and status-header/truncation-footer formatting are tested in `tests/tool-web.spec.ts`, and the README's Known Limitations trades the regex-converter caveat for the bounded degradation cases. The gfm plugin ships no types; `src/turndown-plugin-gfm.d.ts` declares the one imported export over `@types/turndown` (a devDependency). + +The dependency-weight question the proposal flagged resolves in favor of the swap: `@deepseek-ai/dsh-tool-web` is in the single-file-executable closure ([single-exe note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)), and the exe's asset globs would pack ~7.9 MB of the three packages as published — but ~6 MB of that is `@mixmark-io/domino`'s test corpus (`test/**`), with runtime `lib/` at ~550 KB against a ~174 MB artifact, under 0.5% either way. + +## Snapshot coverage + +The previously-missing keyless `web_fetch` snapshot ships with the change as the acp-agent scenario `web-fetch`: `examples/acp-agent/web.cordis.yml` composes the web seam, the real `dsh-web-fetch-local` provider, `tool-web` with `search: false`, and `web-fetch-fixture-server.mjs` — a loopback HTTP fixture on a fixed port (the fetched URL is part of the recorded transcript) serving deterministic HTML with named entities, a GFM table, and nested formatting. Recording and keyless replay both drive the real HTTP fetch and conversion; the pinned tool result is the turndown output, and the scenario pins the `web` header class (the `web_fetch` schema and guidance). + +## Alternatives considered + +- **`@mozilla/readability` + a DOM.** Solves a different problem (content extraction, not conversion) and drags a heavier DOM dependency; the seam only asks for markdown rendering of whatever the fetch returned. +- **Keep the regex converter.** It was an explicit v1 placeholder per its own JSDoc; keeping it meant model-visible quality (tables, images, nested formatting) stayed lost for the cost of maintaining bespoke entity tables. +- **The minimal `entities`-only variant.** The proposal's fallback position: replace only the entity-decoding third of `html.ts` with the zero-dependency `entities` package, deleting less but avoiding the dependency-weight question. Not taken because the closure math above made the weight immaterial while the full swap deletes the whole hand-rolled converter and its documented quality gaps. +- **`turndown-plugin-gfm` (the original) instead of `@joplin/turndown-plugin-gfm`.** The original is unmaintained (last publish 2018); the Joplin fork is current against turndown 7 and actively released. + +## Consequences + +- **Bought**: standards-based model-visible markdown — ordinary tables, images, strikethrough, nested emphasis, fenced code blocks, and the complete named-entity set — plus the deletion of the bespoke converter and its entity tables. +- **Paid**: two runtime dependencies (`turndown` → `@mixmark-io/domino`) enter tool-web and therefore the exe closure (~550 KB of runtime code as measured above); overlong input is converted only through a bounded prefix, pathological nesting falls back to raw HTML, and spanning table cells are flattened because GFM has no corresponding syntax. +- Model-visible output changed on every fetched HTML page; nothing pinned the old output, and the new snapshot pins the new one. + +## Testing + +- `packages/web/tool-web/tests/tool-web.spec.ts` covers the turndown conversion surface (entities, links, tables, nesting, script/style/noscript removal), ignored table spans, source-prefix and complete-output bounds, fast raw-HTML passthrough for deep or deceptively closed nesting, linear handling of malformed tags, the residual converter-throw fallback, and exact and tiny output budgets; per-file coverage on the package src is 100%. +- The `web-fetch` acp-agent snapshot pins the assembled behavior keylessly end to end (real Loader composition, real HTTP fetch, real conversion). diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md new file mode 100644 index 0000000000..44e7d08c1d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 + +Status: implemented + +[English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 + +## 问题 + +`dsh-tool-web` 的 `src/html.ts`(约 86 行,另有约 40 行专属测试;已由本变更删除)曾用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 ``/``/`
  • `,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包(package),因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;此前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 + +## 决策 + +`packages/web/tool-web/src/fetch.ts` 持有一个模块级 [`turndown`](https://github.com/mixmark-io/turndown) 实例(`headingStyle: 'atx'`、`codeBlockStyle: 'fenced'`、`bulletListMarker: '-'`——固定的面向模型呈现方式,不是部署可调项),配合 `@joplin/turndown-plugin-gfm` 的组合 `gfm` 插件提供表格/删除线支持,并用 `remove(['script', 'style', 'noscript'])` 替代旧实现的整体剥离。`formatFetchOutput` 通过 `fetchMaxOutputChars`(默认 200,000)同时限制同步转换的源前缀和完整渲染输出,因此自定义提供方无法在输出上限生效前造成无界的转换工作。随后,HTML 分支对转换做双重防护:保守的线性词法扫描会保守处理注释内容,跳过原始文本元素的内容,正确处理标签内的引号文本,并在栈深超过 512 层时将主体作为原始 HTML 直接透传;当 turndown 拒绝守卫无法建模的标记时,try/catch 同样回退为原始 HTML。GFM 单元格规则被覆写为忽略 `colspan`;Markdown 无法表示它,这也避免了不受信任的数值属性凭空合成任意数量的空单元格。`html.ts` 及其转换测试已删除;源/输出上限、回退以及状态头/截断页脚格式化均在 `tests/tool-web.spec.ts` 中有测试覆盖,README 的 Known Limitations 用有界降级情形替换了正则转换器警示。gfm 插件不带类型声明;`src/turndown-plugin-gfm.d.ts` 基于 `@types/turndown`(devDependency)声明了唯一被导入的导出。 + +提案标记的依赖体积问题的裁决结果支持替换:`@deepseek-ai/dsh-tool-web` 在单文件可执行文件闭包内([single-exe 决策记录](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)),可执行文件的资产 glob 会把这三个包按发布原样打入约 7.9 MB——但其中约 6 MB 是 `@mixmark-io/domino` 的测试语料(`test/**`),运行时 `lib/` 仅约 550 KB,相对约 174 MB 的产物,两种口径都不到 0.5%。 + +## 快照覆盖 + +此前缺失的无密钥 `web_fetch` 快照随本变更以 acp-agent 场景 `web-fetch` 落地:`examples/acp-agent/web.cordis.yml` 组合了 web seam、真实的 `dsh-web-fetch-local` 提供方、`search: false` 的 `tool-web`,以及 `web-fetch-fixture-server.mjs`——一个固定端口(抓取的 URL 是录制 transcript(文本记录)的一部分)上的回环 HTTP fixture,提供包含命名实体、GFM 表格与嵌套格式的确定性 HTML。录制与无密钥回放都驱动真实的 HTTP 抓取与转换;固定住的工具结果就是 turndown 的输出,该场景同时固定 `web` header 类(`web_fetch` 的 schema 与指引)。 + +## 曾考虑的替代方案 + +- **`@mozilla/readability` 加一个 DOM。** 它解决的是另一个问题(内容提取,而非格式转换),还会拖入更重的 DOM 依赖;这个 seam 只要求把抓取返回的内容渲染成 markdown。 +- **保留正则转换器。** 按其自身 JSDoc 的说法,它本来就是明确的 v1 占位实现;保留它意味着模型可见的质量(表格、图片、嵌套格式)继续缺失,代价还是维护一套自制实体表。 +- **仅引入 `entities` 的最小变体。** 提案中的退守方案:只用零依赖的 `entities` 包替换 `html.ts` 中的实体解码部分,删得更少但完全避开依赖体积问题。未采纳:上述闭包测算表明体积无关紧要,而完整替换能删掉整个手写转换器及其记录在案的质量缺口。 +- **用原版 `turndown-plugin-gfm` 而非 `@joplin/turndown-plugin-gfm`。** 原版已无人维护(最后发布于 2018 年);Joplin 分叉与 turndown 7 保持同步并持续发布。 + +## 后果 + +- **收益**:基于标准的模型可见 markdown——普通表格、图片、删除线、嵌套强调、围栏代码块以及完整的命名实体集——并删除了自制转换器及其实体表。 +- **代价**:两个运行时依赖(`turndown` → `@mixmark-io/domino`)进入 tool-web 进而进入可执行文件闭包(如上实测约 550 KB 运行时代码);超长输入只转换有界前缀,病态嵌套回退为原始 HTML,跨列表格单元格会被展平,因为 GFM 没有对应语法。 +- 每个抓取到的 HTML 页面上模型可见的输出都已变化;旧输出本无任何固定,新快照固定了新输出。 + +## 测试 + +- `packages/web/tool-web/tests/tool-web.spec.ts` 覆盖 turndown 转换面(实体、链接、表格、嵌套、script/style/noscript 移除)、被忽略的表格跨列、源前缀与完整输出上限、深层或带欺骗性闭合嵌套的快速原始 HTML 透传、畸形标签的线性处理、残余的转换器抛错回退,以及恰好达到上限和极小的输出预算;该包 src 的逐文件覆盖率为 100%。 +- acp-agent 的 `web-fetch` 快照无密钥地端到端固定组装后的行为(真实 Loader 组合、真实 HTTP 抓取、真实转换)。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index 133198a4d2..d491394db7 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -1,6 +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 -2026-07-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd -2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1 +2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 8e86588f69..18c79bc2d0 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -33,7 +33,7 @@ The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their pr ### Semantic terminal projection -The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix. +The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state. The streaming checkpoint freezes the loader interval while allowing real wall-clock delay across one animation tick, so it pins semantic status rather than whichever spinner glyph the scheduler happened to render. Each expected output projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes. diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index b70a46830f..d1d4a6ca85 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次: ### 语义终端投影 -包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。 +包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval,同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形。 每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index ff72d8e9ee..43ca03dc7a 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -1,6 +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 -2026-07-24-web-gui-browser-e2e-lane.md: c4e34b3f44162c7021cb25681eea7e49ac78f672 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 466e1c0fc16aac21b87b68cfedaec4fb22a417e2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +2026-07-24-web-gui-browser-e2e-lane.md: d9e0a9660ecd6aeb75e835e68f92c0a268423872 +2026-07-24-web-gui-browser-e2e-lane.zh.md: e8c7d1c4596f20d88bd08423549fb6a9f7b0654b diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index c4e34b3f44..d9e0a9660e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -18,7 +18,7 @@ A plain shared-fixture module (the [testing-policy sanctioned shape](../../../.. `launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. -Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. +Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. `seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair). diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 466e1c0fc1..e8c7d1c459 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -18,7 +18,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu `launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 -无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 +无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 `seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例)——绝不裸写文件,因此播种器对桶哈希、文件名编码、压缩一无所知,host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复(resume)的崩溃修复改写)。 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml new file mode 100644 index 0000000000..466071f552 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.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/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 7027a8bde51f81bfa7774743f84639cbd4b667d8 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md new file mode 100644 index 0000000000..958abc4aee --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -0,0 +1,37 @@ +# Agent Note: Adopt execa for hand-rolled test subprocess plumbing + +Status: implemented + +English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) + +## Problem + +Roughly ten e2e/smoke files re-derived the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. + +Two related test-infra hand-rolls compounded the case: + +- `packages/support/llm-mock-server/src/cli.ts` hand-tokenized 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). +- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carried two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies dead. +- The snapshot harness hand-rolled three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. + +## Decision + +- `execa` is a root devDependency and a runtime dependency of `@deepseek-ai/dsh-loader-smoke` (the one `src/` consumer). The listed spawn-collect-timeout sites run through `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut, failed }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. `runLoaderSmoke` passes `input: ''` for its stdin-close contract, and sites whose assertions pin exact stream bytes pass `stripFinalNewline: false`. +- The genuinely custom parts stay custom on top of an execa-owned subprocess: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. `smoke-real.e2e.ts` keeps raw `spawn` for its three long-lived interactive servers — ready-line watching across both streams plus a staged SIGTERM→await→SIGKILL teardown are the whole site, so execa would delete nothing there; its share of this note is the dead `.env` parser. +- `llm-mock-server`'s CLI tokenizes via `parseArgs` (strict, no positionals); numeric coercion, bounds, and cross-option constraints stay manual, and the pinned error-message tests carry `parseArgs`'s own tokenizer texts. +- Both `loadRootEnv` copies are deleted outright: the owning vitest configs (`vitest.web.config.ts` unconditionally, `vitest.snapshot.config.ts` in record mode) load the repo-root `.env` before those files run. +- The four poll loops ride `vi.waitFor` with explicit `{ interval, timeout }` and descriptive errors thrown from the callback; `waitForPersistedTurnStart` captures its malformed-record validation error out of the retry loop so it fails the run immediately instead of being retried until the deadline. + +## Alternatives considered + +- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. +- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn cross-platform timeout, termination, and result-normalization behavior that execa already carries. +- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. + +## Consequences + +- The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch. +- Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this. +- Direct-child timeout termination and exit/signal result normalization are owned by execa across platforms instead of per-site hand-rolls; process-tree termination remains outside these helpers, as the `loader-smoke` README states. Each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. +- execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only). +- The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`. diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md new file mode 100644 index 0000000000..7027a8bde5 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 采用 execa 替换手写的测试子进程管道代码 + +Status: implemented + +[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 + +## 问题 + +大约十个 e2e/冒烟测试文件各自手工重写过同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。 + +另有两处相关的测试基础设施手写代码进一步强化了替换的理由: + +- `packages/support/llm-mock-server/src/cli.ts` 曾手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 +- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 曾携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝实为死代码。 +- 快照 harness 曾手写三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 + +## 决定 + +- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费者)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。 +- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;它在本 note 中的份额是那份死的 `.env` 解析器。 +- `llm-mock-server` 的 CLI 经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,被固定的错误消息测试改为携带 `parseArgs` 自己的切分器文本。 +- 两份 `loadRootEnv` 拷贝被整体删除:拥有它们的 vitest 配置(`vitest.web.config.ts` 无条件、`vitest.snapshot.config.ts` 在 record 模式下)在这些文件运行之前就加载了仓库根部的 `.env`。 +- 那四个轮询循环改乘 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。 + +## 曾考虑的替代方案 + +- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 +- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的跨平台超时、终止与结果规范化行为。 +- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 + +## 后果 + +- 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。 +- 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。 +- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 +- execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。 +- mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml index 5ab7a8229d..6e5f8b8391 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml @@ -1,6 +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 -2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f -2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +2026-07-24-domain-kv-storage-and-workspace.md: 230877628428dc88dbddeecfe5f4353cf15e151d +2026-07-24-domain-kv-storage-and-workspace.zh.md: 050f72cd3327f83e2c3f3cefcab63c01e8f112ee diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md index cd666a47a3..2308776284 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -11,7 +11,9 @@ The host's only persistence surface is the session event log (`packages/session- - **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned). - **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates. -Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code. +Separately, Session deletion needs a `SessionPersistence` delete primitive and a `session.delete` endpoint. That gap's design is settled in this note, but its implementation remains future work. + +The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) supersedes only that coupling: deleting a Workspace registration preserves its Sessions and their logs, while Session deletion remains separate future work. The cascade design below is therefore not the Workspace GUI delete semantic. ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side. - **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace. - Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas. -- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record. +- **Session deletion remains future work.** The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) ships `ctx.workspace.delete(id)` as a metadata-only operation that preserves Sessions and logs. Recursive Session deletion, running checks, and crash-rerun convergence belong to a separate `session.delete` capability. Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline): @@ -285,7 +287,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Not doing | Trigger | Rework point | Groundwork | | --- | --- | --- | --- | -| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with | +| Session deletion (`SessionPersistence.delete`, the deleted event, recursive delete, running checks) | a destructive Session-delete product flow starts | implement the session primitive plus `session.delete`; keep it independent from Workspace registration deletion | orchestration rules and rejection table above remain groundwork; Workspace deletion preserves Sessions and logs | | The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already | | Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only | | Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process | @@ -296,7 +298,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — | | Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow | | Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — | -| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection | +| Session-delete RPC/GUI | a destructive Session-delete product flow starts | `session.delete` endpoint, wire schema, and explicit confirmation UI | Workspace RPC/GUI is shipped separately; no cascade coupling remains | ## Alternatives considered @@ -317,7 +319,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha ## Acceptance criteria - This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine). -- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work). +- `ctx.workspace` completes the create → attach → list → metadata-only delete lifecycle under a test assembly. - Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase). - No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring. diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index 81adf1eb6b..050f72cd33 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -11,7 +11,9 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis - **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。 - **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。 -另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语,host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码。 +另外,Session 删除需要 `SessionPersistence` 删除原语和 `session.delete` 端点。该空白的设计随本 Note 定案,但实现仍属未来工作。 + +后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)取代的仅是上述耦合关系:删除 Workspace 注册记录会保留相关 Session 及其日志,Session 删除仍是独立的未来工作。因此,下文的级联设计并不是 Workspace GUI 的删除语义。 ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。 - **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。 - 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。 -- **workspace 删除整体为 future work**(2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。 +- **Session 删除仍属未来工作。** 后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)已将 `ctx.workspace.delete(id)` 作为仅删除元数据、保留 Session 与日志的操作交付。递归删除 Session、运行中检查和崩溃重跑收敛属于独立的 `session.delete` 能力。 一致性口径(账 = 归属唯一依据;实现与测试基准): @@ -285,7 +287,7 @@ export class WorkspaceRegistry extends Service { | 不做 | 触发条件 | 返工点 | 预埋 | | --- | --- | --- | --- | -| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 | +| Session 删除(`SessionPersistence.delete`、deleted 事件、递归删除、运行中检查) | 破坏性的 Session 删除产品流启动 | 实现 Session 原语及 `session.delete`;与 Workspace 注册记录删除保持独立 | 上文编排规则和拒绝清单仍是基础;Workspace 删除会保留 Session 与日志 | | `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 | | 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 | | 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` | @@ -296,7 +298,7 @@ export class WorkspaceRegistry extends Service { | 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — | | 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 | | session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — | -| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 | +| Session 删除 RPC/GUI | 破坏性的 Session 删除产品流启动 | `session.delete` 端点、wire schema 与明确的确认 UI | Workspace RPC/GUI 已独立交付,不再存在级联耦合 | ## Alternatives considered @@ -317,7 +319,7 @@ export class WorkspaceRegistry extends Service { ## Acceptance criteria - 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。 -- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work)。 +- `ctx.workspace` 可在测试组装下完成 create → attach → list → 仅删除元数据的 delete 生命周期。 - session-persistence 包零 diff(本期不动 session 侧的验收线)。 - 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。 diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md deleted file mode 100644 index 63e3f45ab2..0000000000 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching - -Status: proposed - -English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) - -## Problem - -Five workflows repeat a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — is already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions. - -## Proposal - -Convert the symmetric-cache workflows to `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`. Explicitly do NOT convert: - -- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express; -- the Windows job, which deliberately skips the store cache. - -## Alternatives considered - -- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break. -- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. - -## Acceptance criteria - -- The five symmetric workflows set up pnpm via the actions; one cold run per lane repopulates the new cache-key format, after which cache hit rates match the old steps. -- The enterprise-runner PR jobs and the Windows job are untouched. - -## Risks - -- Cache-key format changes once (one cold run per lane). -- A third-party action in more workflows; it is already trusted in-repo (`landlock-run.yml`) and is the pnpm team's official action. diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md deleted file mode 100644 index 2348e07d58..0000000000 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存 - -Status: proposed - -[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文 - -## 问题 - -五个工作流重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。 - -## 提案 - -将各对称缓存工作流改为 `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`。以下明确不做转换: - -- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的; -- Windows 作业,它刻意跳过 store 缓存。 - -## 曾考虑的替代方案 - -- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 -- **连企业作业在内全部转换。** 否决:只恢复不上传(restore-only)的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 - -## 验收标准 - -- 五个对称工作流经由上述 action 完成 pnpm 设置;每条泳道各跑一次冷运行以重建新的缓存键格式,此后缓存命中率与旧步骤持平。 -- 企业 runner 上的 PR 作业与 Windows 作业保持原样不动。 - -## 风险 - -- 缓存键格式变更一次(每条泳道各一次冷运行)。 -- 更多工作流引入一个第三方 action;它已在仓库内获得信任(`landlock-run.yml`),且是 pnpm 团队的官方 action。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md deleted file mode 100644 index 0bf32e01ea..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Consolidate gate scripts on already-present deps and builtins - -Status: proposed - -English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md) - -## Problem - -The `scripts/` gates mostly use the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-roll what a sibling gate already does with an existing dependency or builtin: - -- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) are two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracts fences by visiting mdast `code` nodes via the shared `scripts/markdown.ts` helpers — and `markdownProseLines` in `markdown.ts` itself parses to mdast but then hand-tracks fence state with a second regex. The regex scanners only recognize backtick fences at column 0, so they silently disagree with the mdast-based gates on tilde and indented fences. -- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) step argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already use the `node:util` `parseArgs` builtin. -- **Hand-rolled directory walks.** Five sites re-derive nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template. - -No new dependency is needed anywhere; every replacement is an existing devDep or a Node builtin. - -## Proposal - -- Extract a shared ~10–15-line mdast fence helper (visiting `code` nodes for `lang`, `meta`, `value`, `position.start.line`) into `scripts/markdown.ts`; rewrite `doc-typecheck.ts` and `verify-type-equiv.ts` onto it; delete `md-fences.ts` and the duplicated scanner; drop the redundant fence regex in `markdownProseLines`. -- Replace both `parseOptions` copies with `parseArgs`. -- Replace the five straggler walks with `globSync`. Keep the walks in `check-workspace-constraints.ts` and `clean.ts`: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report. - -## Alternatives considered - -- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these are stragglers, not a gap. -- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../../implemented/process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer. -- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation is a latent inconsistency between sibling gates. - -## Acceptance criteria - -- `md-fences.ts` is gone; `doc-typecheck` and `verify-type-equiv` extract fences through `scripts/markdown.ts`; `pnpm run doc-sync` passes with unchanged results on the current tree (any delta traces to a fence shape the regex scanners mishandled). -- Both CLIs parse via `parseArgs`; unknown options still fail loud. -- The five walk sites use `globSync`; the gates they feed pass unchanged. - -## Risks - -- Behavioral deltas on pathological markdown: mdast honors tilde/indented fences the regex scanners ignored, so `doc-typecheck`'s opt-out ratio could shift if any stray fence shape exists in the docs tree; verify by running `doc-sync` before/after. -- `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the current parsers.) diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md deleted file mode 100644 index ba9f157a61..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 把门禁脚本统一到已有依赖与内置模块上 - -Status: proposed - -[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文 - -## 问题 - -`scripts/` 下的门禁大多已经在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本仍在手写同类门禁早已用既有依赖或内置模块完成的事情: - -- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 已经通过共享的 `scripts/markdown.ts` 辅助函数访问 mdast `code` 节点来提取代码围栏;`markdown.ts` 自己的 `markdownProseLines` 也是先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 -- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)已经在使用 `node:util` 的内置 `parseArgs`。 -- **手写的目录遍历。**五处代码各自重写了 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 - -所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 - -## 提案 - -- 在 `scripts/markdown.ts` 中提取一个约 10–15 行的共享 mdast 围栏辅助函数(访问 `code` 节点,读取 `lang`、`meta`、`value`、`position.start.line`);把 `doc-typecheck.ts` 和 `verify-type-equiv.ts` 改写到它上面;删除 `md-fences.ts` 和重复的扫描器;去掉 `markdownProseLines` 中冗余的围栏正则。 -- 用 `parseArgs` 替换两份 `parseOptions` 拷贝。 -- 用 `globSync` 替换那五处掉队的目录遍历。保留 `check-workspace-constraints.ts` 和 `clean.ts` 中的遍历:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。 - -## 曾考虑的替代方案 - -- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。 -- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../../implemented/process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。 -- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。 - -## 验收标准 - -- `md-fences.ts` 已删除;`doc-typecheck` 与 `verify-type-equiv` 通过 `scripts/markdown.ts` 提取代码围栏;`pnpm run doc-sync` 在当前代码树上通过且结果不变(如有差异,必须能追溯到正则扫描器处理有误的某种围栏形态)。 -- 两个 CLI 都改用 `parseArgs` 解析;未知选项仍然大声失败。 -- 五处遍历代码改用 `globSync`;它们供给的门禁保持原样通过。 - -## 风险 - -- 病态 markdown 上的行为差异:mdast 会承认正则扫描器忽略的波浪线围栏和缩进围栏,因此如果文档树中存在任何零散的此类围栏形态,`doc-typecheck` 的 opt-out 比例可能变化;应在改动前后分别运行 `doc-sync` 加以验证。 -- `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与现有解析器行为一致。) diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md deleted file mode 100644 index 8a93b7f6c7..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser - -Status: proposed - -English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md) - -## Problem - -`packages/llm/llm-deepseek/src/sse.ts` hand-implements Server-Sent Events parsing: a streaming `TextDecoder`, event-block splitting on `\r?\n\r?\n`, `data:` payload extraction and joining, comment/field skipping, the `[DONE]` sentinel, a `STREAM_CLOSED` error on EOF without it, and a flush of a final unterminated event block. The file is ~67 lines with ~108 lines of dedicated tests (`tests/sse.spec.ts`) re-proving SSE spec behavior — UTF-8 split across chunks, CRLF handling, multi-`data:` joining, no-space-after-colon — that a maintained parser already guarantees. Its only consumer is `adapter.ts` (`yield* translate(parseSse(response.body))`). - -This is exactly the surface `eventsource-parser` owns: the de-facto standard SSE parser (it underlies the Vercel AI SDK and the MCP SDK), zero-dependency, actively maintained, and already present in this repo's lockfile transitively via `@modelcontextprotocol/sdk` — so adopting it directly adds no new supply-chain surface in practice. - -## Proposal - -Replace `sse.ts` with `EventSourceParserStream` from `eventsource-parser/stream`: `response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream())`, keeping only the DeepSeek protocol shim (~10–25 lines): yield each event's `data`, terminate on `[DONE]`, and throw `LlmError('STREAM_CLOSED')` when the stream ends without the sentinel. All required builtins (`TextDecoderStream`, `pipeThrough`, async-iterable `ReadableStream`) exist at the Node ^22.19 engine floor. Delete the spec-conformance tests; keep the `[DONE]`/`STREAM_CLOSED`/EOF contract tests. Add `eventsource-parser` to `llm-deepseek`'s dependencies (its second runtime dep after schemastery). Update the [twin-adapters note](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) and the `dsh-llm` JSDoc that brand this adapter "hand-rolled fetch + SSE parsing" in the same PR. - -The library also strips a leading BOM (the hand-rolled parser would fail to match `data:` after one) and offers `maxBufferSize` hardening the current parser lacks. - -## Alternatives considered - -- **Keep the hand-rolled parser.** Defensible under the [twin-adapters decision](../../implemented/architecture/2026-06-13-twin-llm-adapters.md): the adapter is deliberately the hand-rolled design-verification twin of the pi-ai adapter. But the note's load-bearing distinction is owning the fetch/translate internals versus delegating to a full provider SDK; a ~700-byte SSE micro-parser is transport plumbing, not the design under verification. Whether that reading stands is the twin-note owner's call — this proposal explicitly needs their sign-off. -- **`createParser({onEvent})` callback API instead of the stream.** Works fed by a manual `TextDecoder` loop, but the `pipeThrough` composition deletes more of the hand-rolled code. - -## Acceptance criteria - -- `sse.ts`'s parsing internals are gone; the remaining shim only encodes the DeepSeek `[DONE]`/`STREAM_CLOSED` protocol. -- `llm-deepseek` unit tests and the real-API e2e suite pass; keyless snapshots are unchanged (parsing is transport-internal and payload extraction is equivalent). -- The twin-adapters note and `dsh-llm` JSDoc no longer claim hand-rolled SSE parsing. - -## Risks - -- One deliberate robustness deviation is lost: the hand-rolled parser flushes a final event block that lacks its terminating blank line, and `tests/sse.spec.ts` pins that a trailing `data: [DONE]` without `\n\n` still yields DONE. eventsource-parser is spec-strict and only dispatches on the blank line, so that shape becomes `STREAM_CLOSED`. Real providers and `dsh-llm-mock-server` always terminate events properly, so the pinned behavior is a robustness nicety, not an observed provider shape — drop the test, or keep a tiny buffer-tail check if the deviation is judged load-bearing. -- Dilutes the documented "hand-rolled" identity of the twin adapter; mitigated by updating the note in the same change rather than leaving the claim stale. diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md deleted file mode 100644 index b16109d945..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器 - -Status: proposed - -[English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文 - -## 问题 - -`packages/llm/llm-deepseek/src/sse.ts` 手写实现了 SSE(Server-Sent Events)解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。 - -这恰好是 `eventsource-parser` 负责的接口面:事实标准的 SSE 解析器(Vercel AI SDK 和 MCP SDK 都构建在它之上),零依赖,持续维护,并且已通过 `@modelcontextprotocol/sdk` 作为传递依赖出现在本仓库的 lockfile 中——因此直接采用它实际上不增加新的供应链接触面。 - -## 提案 - -用 `eventsource-parser/stream` 的 `EventSourceParserStream` 替换 `sse.ts`:`response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream())`,只保留 DeepSeek 协议垫层(约 10–25 行):逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。删除规范符合性测试;保留 `[DONE]`/`STREAM_CLOSED`/EOF 契约测试。将 `eventsource-parser` 加入 `llm-deepseek` 的依赖(这是它继 schemastery 之后的第二个运行时依赖)。在同一个 PR(Pull Request)中更新[孪生适配器 Agent Note(agent 决策记录)](../../implemented/architecture/2026-06-13-twin-llm-adapters.md)以及 `dsh-llm` 中把该适配器标为「手写 fetch + SSE 解析」的 JSDoc。 - -该库还会剥离开头的 BOM(手写解析器在 BOM 之后会无法匹配 `data:`),并提供当前解析器缺少的 `maxBufferSize` 加固能力。 - -## 曾考虑的替代方案 - -- **保留手写解析器。** 依据[孪生适配器决策](../../implemented/architecture/2026-06-13-twin-llm-adapters.md),这一选择有辩护余地:该适配器有意作为 pi-ai 适配器的手写设计验证孪生体。但那份 Agent Note 起支撑作用的区分在于「自行持有 fetch/translate 内部实现」与「委托给完整的提供方 SDK」;一个约 700 字节的 SSE 微型解析器属于传输层管道,不是被验证的设计本身。这一解读是否成立由孪生 Agent Note 的所有者裁定——本提案明确需要其签署确认。 -- **改用 `createParser({onEvent})` 回调 API 而非流。** 配合手动的 `TextDecoder` 循环可以工作,但 `pipeThrough` 组合方式能删除更多手写代码。 - -## 验收标准 - -- `sse.ts` 的解析内部实现消失;剩下的垫层只编码 DeepSeek 的 `[DONE]`/`STREAM_CLOSED` 协议。 -- `llm-deepseek` 单元测试与真实 API 的 e2e 套件通过;无密钥快照不变(解析属于传输层内部,载荷提取等价)。 -- 孪生适配器 Agent Note 与 `dsh-llm` 的 JSDoc 不再声称手写 SSE 解析。 - -## 风险 - -- 会失去一处有意为之的健壮性偏离:手写解析器会 flush 缺少终结空行的最后一个事件块,`tests/sse.spec.ts` 固定了「末尾的 `data: [DONE]` 即使没有 `\n\n` 也仍产出 DONE」这一行为。eventsource-parser 严格遵循规范,只在空行处分发事件,因此这种形态会变成 `STREAM_CLOSED`。真实提供方和 `dsh-llm-mock-server` 总是正确终结事件,所以被固定的行为只是健壮性上的锦上添花,并非实际观测到的提供方形态:可以删除该测试;若判定该偏离确有支撑作用,也可以保留一个小型的缓冲区尾部检查。 -- 稀释了孪生适配器有文档记录的「手写」身份;缓解方式是在同一次变更中更新那份 Agent Note,而不是让声明陈旧下去。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md deleted file mode 100644 index 7f25e51bf6..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown - -Status: proposed - -English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) - -## Problem - -`packages/web/tool-web/src/html.ts` (~86 lines, ~40 lines of dedicated tests) converts fetched HTML to markdown with regexes: strip script/style/noscript/comments, convert ``/``/`
  • `, decode numeric entities plus a 12-entry named-entity table, collapse whitespace. The module's own JSDoc says "A richer converter can replace it without changing the seam or tool schema", and the README's Known Limitations documents it as "a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost." The [web capability seam note](../../implemented/architecture/2026-06-24-web-capability-seam.md) assigns HTML→markdown to this package as presentation, so the swap point is exactly here. The converter's output is model-visible on every fetched HTML page; no keyless snapshot currently exercises `web_fetch`, so no expected outputs pin it. - -## Proposal - -Replace `htmlToMarkdown` with `turndown` (`new TurndownService().turndown(html)`), optionally with `turndown-plugin-gfm` for tables. The consumer switch in `fetch.ts` and the status-header/truncation-footer formatting stay. Wrap the call in try/catch falling back to the raw text path: the regex version could never throw; turndown on pathological HTML could. Delete `html.ts` and its conversion tests; keep tests for the fallback and the surrounding formatting. Update the README's Known Limitations to drop the regex-converter caveat. - -If the "deliberately minimal fallback" stance is preferred instead, a minimal variant still deletes the worst part: replace the entity-decoding third of the file (~30 lines: `decodeEntities`, `NAMED_ENTITIES`, `safeFromCodePoint`) with the zero-dependency `entities` package (already in the lockfile transitively), erasing the documented "about a dozen entities" limitation at near-zero risk. - -## Alternatives considered - -- **`@mozilla/readability` + a DOM.** Solves a different problem (content extraction, not conversion) and drags a heavier DOM dependency; the seam only asks for markdown rendering of whatever the fetch returned. -- **Keep the regex converter.** It was an explicit v1 placeholder per its own JSDoc; keeping it means model-visible quality (tables, images, nested formatting) stays lost for the cost of maintaining bespoke entity tables. -- **The minimal `entities`-only variant.** Kept in the proposal as the fallback position; it deletes less but avoids the dependency-weight question entirely. - -## Acceptance criteria - -- `web_fetch` renders tables/nested formatting via turndown (or, minimal variant: decodes all named entities), with the README limitation updated. -- Unit tests cover the fallback path; `pnpm run test` passes for the package. -- A keyless snapshot exercising `web_fetch` markdown rendering is added per testing policy (the missing snapshot coverage is part of the change, and it pins the new output). - -## Risks - -- Model-visible output changes on every fetched HTML page — transcript drift is acceptable pre-release, and nothing currently pins the old output. -- Dependency weight: turndown's one dependency (`@mixmark-io/domino`) is a ~200 KB DOM that would enter the single-file-executable closure if tool-web ships in it ([single-exe note](../../implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)); the minimal `entities` variant avoids this if closure size is the deciding factor. diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md deleted file mode 100644 index 3a59b08e13..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 - -Status: proposed - -[English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 - -## 问题 - -`packages/web/tool-web/src/html.ts`(约 86 行,另有约 40 行专属测试)用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 ``/``/`
  • `,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../../implemented/architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包(package),因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;当前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 - -## 提案 - -用 `turndown` 替换 `htmlToMarkdown`(`new TurndownService().turndown(html)`),可选择配合 `turndown-plugin-gfm` 支持表格。`fetch.ts` 中的消费方分支与状态头、截断页脚的格式化保持不变。把调用包在 try/catch 中,失败时回退到原始文本路径:正则版本从不可能抛异常,而 turndown 处理病态 HTML 时可能抛出。删除 `html.ts` 及其转换测试;保留回退路径与外围格式化的测试。更新 README 的 Known Limitations 章节,移除正则转换器的警示说明。 - -如果更倾向于「刻意保持最小回退实现」的立场,最小变体仍能删掉最糟的部分:用零依赖的 `entities` 包(已通过传递依赖存在于 lockfile 中)替换文件中占三分之一的实体解码部分(约 30 行:`decodeEntities`、`NAMED_ENTITIES`、`safeFromCodePoint`),以近乎为零的风险抹掉文档记载的「about a dozen entities」限制。 - -## 曾考虑的替代方案 - -- **`@mozilla/readability` 加一个 DOM。** 它解决的是另一个问题(内容提取,而非格式转换),还会拖入更重的 DOM 依赖;这个 seam 只要求把抓取返回的内容渲染成 markdown。 -- **保留正则转换器。** 按其自身 JSDoc 的说法,它本来就是明确的 v1 占位实现;保留它意味着模型可见的质量(表格、图片、嵌套格式)继续缺失,代价还是维护一套自制实体表。 -- **仅引入 `entities` 的最小变体。** 已作为退守方案保留在提案中;它删得更少,但完全避开了依赖体积问题。 - -## 验收标准 - -- `web_fetch` 经由 turndown 渲染表格与嵌套格式(或在最小变体下:解码全部命名实体),README 中的限制说明同步更新。 -- 单元测试覆盖回退路径;该包的 `pnpm run test` 通过。 -- 按测试政策补充一个执行 `web_fetch` markdown 渲染的无密钥快照(缺失的快照覆盖是本变更的一部分,它同时固定新输出)。 - -## 风险 - -- 模型可见的输出在每个抓取到的 HTML 页面上都会变化:预发布阶段的 transcript(文本记录)漂移可以接受,且当前没有任何东西固定旧输出。 -- 依赖体积:turndown 的唯一依赖(`@mixmark-io/domino`)是一个约 200 KB 的 DOM 实现,若 tool-web 进入单文件可执行文件,它会一并进入闭包([single-exe 决策记录](../../implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md));若闭包体积是决定因素,最小的 `entities` 变体可以避开这一点。 diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md deleted file mode 100644 index 99a86258fe..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Adopt execa for hand-rolled test subprocess plumbing - -Status: proposed - -English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) - -## Problem - -Roughly ten e2e/smoke files re-derive the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. Net deletable: ~100–150 lines of test infrastructure. - -Two related test-infra hand-rolls compound the case: - -- `packages/support/llm-mock-server/src/cli.ts` hand-tokenizes 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). -- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carry two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies arguably dead. -- The snapshot harness hand-rolls three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. - -## Proposal - -- Add `execa` as a root devDependency and rewrite the spawn-collect-timeout sites onto `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. Keep the genuinely custom parts custom: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. -- Swap `llm-mock-server`'s CLI tokenizer for `parseArgs` (numeric coercion, bounds, and cross-option constraints stay manual; pinned error-message texts update with the tests). -- Delete both `loadRootEnv` copies in favor of `process.loadEnvFile` in a try/catch, or remove them outright if the vitest-config loading already covers them. -- Replace the four poll loops with `vi.waitFor`/`expect.poll`, passing explicit `{ interval, timeout }` and throwing descriptive errors from the callback. - -## Alternatives considered - -- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. -- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. -- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. - -## Acceptance criteria - -- The listed sites spawn through execa (or the chosen equivalent); the hand-rolled collect/timeout blocks and the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke` are gone. -- `llm-mock-server` CLI parses via `parseArgs`; its cli spec passes with updated message expectations. -- No hand-rolled `.env` parser remains under `apps/web/tests`. -- The affected e2e and snapshot suites pass on both POSIX and Windows CI lanes. - -## Risks - -- `loader-smoke` is a `src/` file under the per-file-100% coverage gate; the swap actually simplifies its coverage story (removes un-inducible branches) but the new call shape needs coverage. -- Each rewritten e2e must be re-run on both platforms; subtle differences in kill escalation or stdin-close semantics (`input: ''` for loader-smoke's stdin-close contract) are the risk to verify per site. -- execa is a new root devDependency (currently absent from the lockfile entirely); it is one of the most-depended-on packages on npm and actively maintained, so health is not a concern, but the exe/runtime closure is unaffected either way (tests only). diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md deleted file mode 100644 index 525e09f07c..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 采用 execa 替换手写的测试子进程管道代码 - -Status: proposed - -[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 - -## 问题 - -大约十个 e2e/冒烟测试文件各自手工重写同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。净可删除量:约 100–150 行测试基础设施代码。 - -另有两处相关的测试基础设施手写代码进一步强化了替换的理由: - -- `packages/support/llm-mock-server/src/cli.ts` 手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 -- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝几乎可以视为死代码。 -- 快照 harness 手写了三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 - -## 提案 - -- 将 `execa` 添加为根 devDependency,把上述 spawn、收集、超时的代码位置改写到 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 上:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。真正定制的部分继续保持定制:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。 -- 把 `llm-mock-server` 的 CLI 切分器换成 `parseArgs`(数值转换、边界检查与跨选项约束仍手工实现;被固定的错误消息文本随测试一并更新)。 -- 删除两份 `loadRootEnv` 拷贝,改用包在 try/catch 中的 `process.loadEnvFile`;如果 vitest 配置的加载已经覆盖了它们,则直接整体移除。 -- 用 `vi.waitFor`/`expect.poll` 替换那四个轮询循环,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误。 - -## 曾考虑的替代方案 - -- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 -- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 -- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 - -## 验收标准 - -- 所列位置全部通过 execa(或最终选定的等价包)spawn 子进程;手写的收集/超时代码块,连同 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支,全部移除。 -- `llm-mock-server` 的 CLI 经由 `parseArgs` 解析;其 cli 测试文件在更新消息期望后通过。 -- `apps/web/tests` 下不再存在手写的 `.env` 解析器。 -- 受影响的 e2e 与快照测试套件在 POSIX 与 Windows 两条 CI 车道上均通过。 - -## 风险 - -- `loader-smoke` 是逐文件 100% 覆盖率门禁下的 `src/` 文件;这次替换实际上简化了它的覆盖率问题(移除了无法人为诱发的分支),但新的调用形态需要补齐覆盖。 -- 每个改写后的 e2e 都必须在两个平台上重新运行;终止信号升级或 stdin 关闭语义上的细微差异(loader-smoke 的 stdin 关闭契约对应 `input: ''`)是需要逐处核验的风险。 -- execa 是新增的根 devDependency(当前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,健康度不是顾虑;至于 exe/运行时闭包,无论选哪个包都不受影响(仅测试使用)。 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml similarity index 58% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml index 56e0178e8d..2dc0338121 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.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 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 047449f4915c973e86cdb9f05f6dc51535133534 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 379d57e1e0006bf8f567d0b750ca0bb641ca6b49 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 236139f9198f178d44cdf0867cbad2377a127359 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 3932f73a2bf147ce5088b5c42e85982c70cdb945 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md index 047449f491..236139f919 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md @@ -1,6 +1,6 @@ # Agent Note: Evaluate landstrip before building a Windows sandbox launcher -Status: proposed +Status: rejected — landstrip is not battle-tested (a days-old single-maintainer project, ~48 GitHub stars at rejection); a security-invariant dependency must have proven adoption, so the win32 rung keeps the in-house-launcher plan English | [中文](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md) diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md index 379d57e1e0..3932f73a2b 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md @@ -1,6 +1,6 @@ # Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip -Status: proposed +Status: rejected — landstrip 未经实战检验(问世仅数天、单一维护者、驳回时 GitHub 星标约 48 个);安全不变式级的依赖必须有成熟的采用度,因此 win32 梯级维持自研启动器的原计划 [English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index b01cf8abfe..082ac040e9 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.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 -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: a1d15b89f85b41e1044d9597dee6a1a0190240e6 -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 4893784effdee7605f9194a80010b5a5033edbc1 +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 9cdb061bf21a7b9ce4747b9022c65d60e9644e0d +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 9648af149c0c517eba372baa12877240f9289aac diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index a1d15b89f8..9cdb061bf2 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -18,7 +18,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`vscode-languageserver-types` for lsp-local's wire-type subset**: ~80 type lines and ~45 guard lines, but upstream guards differ in both directions (accept `uri: undefined` the repo must reject; require `targetRange` the repo tolerates absent), and the initialize-result shapes live in `vscode-languageserver-protocol`, dragging `vscode-jsonrpc` in as a runtime dep — ~1 MB for 80 spec-exact lines. - **`json-rpc-2.0` for `dsh-jsonrpc`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [GUI RPC note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). - **`jsonrpcclient` for the Python SDK client**: v4 builds/parses messages only — ~20 lines — while the 500 lines that matter (subprocess lifecycle, threaded reader, id correlation, bidirectional server-role responses) stay; the library is in low-maintenance mode. -- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) +- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) **Retry, timers, async:** @@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line. - **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing). - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does. -- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa proposal](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) +- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill. - **node-pty everywhere for the TUI test driver**: [Windows-TUI note](../../implemented/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it is already the Windows leg. @@ -67,7 +67,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`syncpack`/`manypkg` for `check-workspace-constraints.ts`**: they cover ~20 lines of range alignment; the load-bearing 200+ lines (computed `files` lists, cordis peer=dev pairing, hierarchy shape) are repo policy no generic engine expresses. - **`remark-validate-links` for `verify-md-links.ts`**: the gate rides the repo's shared mdast toolchain; adopting remark-cli adds a second markdown stack to delete one small file. - **`prebuildify`/`node-gyp-build` for the landlock launcher packaging**: inapplicable — those load `.node` addons via dlopen; the launcher ships a standalone exec'd static binary, and per-platform `optionalDependencies` *is* the ecosystem convention for binaries. -- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung is a different question — see the [landstrip evaluation proposal](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md).) +- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung was weighed separately and also [rejected](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — landstrip is not battle-tested.) - **`hatch-nodejs-version` for Python release versioning**: roughly LOC-neutral (a custom metadata hook replaces the regex), inverts the recorded decision that the dev sentinel never determines a release version, and puts a single-maintainer build plugin in the release supply chain. - **YAML consolidation (`js-yaml` vs `yaml`)**: the repo carries both parsers, with the `!!js` tag defined four times on js-yaml (vendored include, app-boot, apps/cli, `scripts/verify-cordis-config.ts`) and twice on `yaml` (sdk-telemetry's `ScalarTag`, sdk-helper's comment-preserving Document editing). The direction is forced — js-yaml cannot replace `yaml` (sdk-helper needs the Document API) — but migrating the js-yaml sites cannot retire the library either (the vendored include pins it) and would put two parsers in charge of one dialect that must agree exactly, against the [personal-config note](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)'s deliberate load-only-copy parity. Deletable: ~20–25 lines of duplicate tag definitions and two `@types/js-yaml` entries. The consolidation moment is a future include sync, not now. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 4893784eff..9648af149c 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -18,7 +18,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `vscode-languageserver-types` 承担 lsp-local 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。 - **以 `json-rpc-2.0` 替换 `dsh-jsonrpc`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 - **以 `jsonrpcclient` 承担 Python SDK 客户端**:v4 只做消息的构造/解析——约 20 行——而真正要紧的 500 行(子进程生命周期、线程化读取器、id 关联、双向的服务端角色应答)全都保留;该库处于低维护模式。 -- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) +- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) **重试、定时器与异步:** @@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。 - **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。 -- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa 提案](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) +- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。 - **在 TUI 测试驱动器上到处使用 node-pty**:[Windows TUI 决策](../../implemented/feature/2026-07-20-windows-tui-support.md)已明确否决在每个宿主上都用 node-pty;它已经是 Windows 那一条腿。 @@ -67,7 +67,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `syncpack`/`manypkg` 替换 `check-workspace-constraints.ts`**:它们只覆盖约 20 行的版本范围对齐;承重的 200+ 行(计算生成的 `files` 列表、cordis peer=dev 配对、层级形状)是仓库政策,没有通用引擎能表达。 - **以 `remark-validate-links` 替换 `verify-md-links.ts`**:该门禁搭载仓库共享的 mdast 工具链;采用 remark-cli 等于为删掉一个小文件而增加第二套 markdown 技术栈。 - **以 `prebuildify`/`node-gyp-build` 承担 landlock 启动器打包**:不适用——那些工具通过 dlopen 加载 `.node` addon;这个启动器交付的是独立 exec 的静态二进制,而按平台划分的 `optionalDependencies` 恰恰*就是*二进制分发的生态惯例。 -- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级是另一个问题——见 [landstrip 评估提案](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。) +- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级经单独权衡后同样被[驳回](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——landstrip 未经实战检验。) - **以 `hatch-nodejs-version` 承担 Python 发布版本号**:代码行数大致持平(一个自定义 metadata 钩子换掉那个正则),却反转了「dev 哨兵值绝不决定发布版本」这条记录在案的决策,还把一个单一维护者的构建插件放进发布供应链。 - **YAML 归一(`js-yaml` 与 `yaml`)**:仓库同时携带两个解析器,`!!js` 标签在 js-yaml 上定义了四次(vendor 收录的 include、app-boot、apps/cli、`scripts/verify-cordis-config.ts`),在 `yaml` 上定义了两次(sdk-telemetry 的 `ScalarTag`、sdk-helper 的保留注释式 Document 编辑)。方向是被迫的——js-yaml 无法取代 `yaml`(sdk-helper 需要 Document API)——但迁移 js-yaml 各调用点也退休不了这个库(vendor 收录的 include 锁定了它),还会让两个解析器共管一种必须完全一致的方言,违背[个人配置决策](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)刻意的「仅加载副本」对等性。可删除的:约 20–25 行重复标签定义和两条 `@types/js-yaml` 条目。归一的时机是未来某次 include 同步,不是现在。 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 5f03c8617d..ff4fd4e6b2 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run Windows jobs under native `pwsh`. +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md). diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 48c96e4d20..b119a548b8 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -124,9 +124,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # setup-node's built-in pnpm store cache keys on platform AND arch, so + # the Linux architectures sharing runner.os stay on separate caches. - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm - uses: actions/setup-python@v6 with: @@ -135,21 +140,6 @@ jobs: - name: Install Python build tooling run: python -m pip install uv==0.11.23 - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - # Linux architectures share runner.os, so the cache key includes arch. - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm- - # Cache pkg's target Node binary; lockfile changes roll the # exact key while the restore prefix can seed its replacement. - uses: actions/cache@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f02d30a563..aecff76a7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,24 @@ jobs: # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. + # + # FAILOVER: each Linux enterprise job resolves its pool through the + # DSH_CI_FAILOVER repository variable. Unset (normal), the expressions + # pick the hosted enterprise pools below. Setting the variable to + # 'selfhosted' (repo Settings → Actions → Variables; writer-manageable + # repository state — not PR-editable, no merge required) retargets all + # three onto the in-house + # vm-backup pool and re-running the failed jobs is the entire switch — + # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The + # in-house pool's readiness is re-proven on every master push by the + # serial-linux-selfhosted standby lane below. node-24: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / static env: DSH_GATE_CONCURRENCY: '8' @@ -43,23 +58,33 @@ jobs: fetch-depth: 0 persist-credentials: false - # Pull requests consume the default-branch cache but do not put cache - # compression and upload on the paid latency-critical path. - - uses: actions/cache/restore@v4 - with: - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and install dependencies + - name: Configure pnpm store path + id: pnpm-store run: | - corepack enable - pnpm install --frozen-lockfile + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Pull requests consume the default-branch cache but do not put cache + # compression and upload on the paid latency-critical path. Skipped + # under failover — see the coverage lane's identical rationale. + - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile - name: Run static gates env: @@ -81,30 +106,52 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-24-04-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-24-04-32core-test' }} name: node 24 / coverage env: - DSH_COVERAGE_MAX_WORKERS: '24' + # Failover shrinks the worker bound: the hosted 32-core runner is + # exclusive to one job, but the failover pool shares one 64-core VM + # across six always-on runner instances, and the timing-sensitive + # process suites have documented aggregate-contention failures. + # 8 × 6 instances = 48 workers worst case on 64 cores. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '24' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/cache/restore@v4 - with: - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack, install dependencies, and prepare bubblewrap + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Skipped under failover: the self-hosted VM's persistent pnpm store + # already serves warm installs, while restoring the hosted archive + # would spend ~52 s pulling ~180 MB into that populated store. + - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install dependencies and prepare bubblewrap run: | - corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & @@ -122,7 +169,11 @@ jobs: node-24-consumers: needs: node-24 if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / snapshots and artifacts env: DSH_ESLINT_CACHE: '1' @@ -130,7 +181,8 @@ jobs: DSH_GATE_CONCURRENCY: '8' DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_PUBLINT_CONCURRENCY: '8' - DSH_SNAPSHOT_MAX_CONCURRENCY: '32' + # Failover halves snapshot concurrency for the shared 64-core VM. + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: - uses: actions/checkout@v6 with: @@ -144,13 +196,6 @@ jobs: - name: Restore built tree run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - - uses: actions/cache/restore@v4 - with: - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache/restore@v4 with: path: .cache/eslint @@ -158,13 +203,31 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack, install dependencies, and prepare bubblewrap + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Skipped under failover — see the coverage lane's identical rationale. + - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install dependencies and prepare bubblewrap run: | - corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & @@ -240,22 +303,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node }}-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -281,41 +334,112 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # One standard Windows box shares setup across the required build/site checks - # and the observational portability inventory. Serial worker bounds keep this - # recovery path portable; Linux owns duplicate lint, coverage, and snapshots. + # The required pull-request Windows signal: the two blocking win32 surfaces + # (workspace build, production site) execute with real, checksum-verified + # Windows Node under Wine on standard hosted Linux. The master + # serial-windows job below keeps the complete native-kernel inventory — + # including the observational portability gates this lane does not run — + # on real windows-2025. This job only provisions runner state (caches, + # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same + # script the optional local gate `pnpm run check:windows-wine` runs. + # Layout, fidelity limits, and measured timings live in + # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md windows: if: github.event_name == 'pull_request' - runs-on: windows-2025 - name: windows node 24 / complete - env: - DSH_COVERAGE_MAX_WORKERS: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_PUBLINT_CONCURRENCY: '1' + runs-on: ubuntu-latest + name: windows node 24 / wine blocking + timeout-minutes: 15 steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - # Extracting the many-file pnpm store cache is slower than a clean install, - # and saving it adds more latency after gates. - - name: Enable corepack and install (immutable) - shell: pwsh + - name: Configure pnpm store path + id: pnpm-store run: | - corepack enable - pnpm install --frozen-lockfile + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" - - name: Run blocking and observational Windows gates concurrently - shell: pwsh - run: pnpm run check:ci:windows-complete + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + # Master's wine-apt-cache job seeds the default-branch scope every pull + # request can read; a save from this job only reaches reruns of the + # same merge ref. + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + # Runner provisioning only — a developer machine installs Wine through + # its own package manager; the gate script assumes a wine binary and + # fails loud without one. Wine from the apt cache when present; else + # download the full dependency closure once and keep it for the next + # run. The `wine` dispatcher package (not bare `wine64`) is what puts a + # binary on PATH. + - name: Install Wine + run: | + if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true + sudo apt-get install -y --no-install-recommends wine + fi + + - name: Run the Wine Windows gates + run: bash scripts/wine-windows-gates.sh + + - name: Shut down wineserver + if: always() + run: wineserver -k 2>/dev/null || true + + # Master seeds the Wine apt-archive cache in the default-branch scope, + # which every pull request's windows job can restore; saves from + # pull-request runs are scoped to their own merge ref and help nobody + # else. Runs in seconds when the image version already has a cache. + wine-apt-cache: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: wine apt cache + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + id: wine-cache + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + - name: Download the Wine dependency closure + if: steps.wine-cache.outputs.cache-hit != 'true' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" + du -sh "$HOME/wine-debs" # Master pushes run only the serial reference jobs below. # Each host executes the complete, unsharded primary Node aggregate with one @@ -330,17 +454,26 @@ jobs: with: fetch-depth: 2 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and resolve pnpm store path + - name: Configure pnpm store path id: pnpm-store run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" # Master refreshes the caches that pull requests restore without saving. + # The store cache stays a hand-rolled actions/cache step rather than + # setup-node's `cache: pnpm`: the enterprise pull-request jobs above + # restore exactly this key and path, and setup-node's built-in cache + # uses its own key format — converting this producer would silently + # starve their documented restore-only optimization. - uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} @@ -372,6 +505,58 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + # Hot-standby drill for the in-house self-hosted pool: every master move + # re-runs the complete unsharded aggregate on the persistent 64-core VM, + # continuously proving that environment can take over a required lane if + # the hosted pools degrade (the switch is then setting the writer-manageable + # DSH_CI_FAILOVER variable — see the failover runbook, no merge required). + # Push-triggered, so this lane always executes the base branch's own + # workflow definition. (Under failover, pull_request jobs do reach these + # runners with the PR merge ref's workflow — the boundary there is + # repository membership: private, forking disabled, Dependabot excluded.) + # Non-blocking for + # pull requests; no cache steps because the VM's persistent pnpm store and + # tool caches make them redundant (and saving here would poison the hosted + # cache namespace with self-hosted paths). + serial-linux-selfhosted: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: serial / linux (self-hosted standby) + runs-on: [self-hosted, linux, x64, vm-backup] + steps: + # Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive + # comparison as serial-linux. Depth 2 would miss github.event.before + # on multi-commit or force pushes; full fetch is cheap here because + # checkout resolves against the VM's local mirror. + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure persistent pnpm store + run: echo "PNPM_CONFIG_STORE_DIR=$HOME/.local/share/pnpm/store" >> "$GITHUB_ENV" + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Prepare bubblewrap (unrestrict userns) + run: bash scripts/prepare-ci-bubblewrap.sh + + - name: Run complete unsharded primary Node CI serially + env: + DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} + DSH_COVERAGE_MAX_WORKERS: '1' + DSH_E2E_MAX_WORKERS: '1' + DSH_ESLINT_CACHE: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + run: pnpm run check:ci + serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos @@ -379,13 +564,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - run: corepack enable - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -411,14 +595,12 @@ jobs: reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - shell: pwsh - run: corepack enable - # Master refreshes the small cache that pull requests restore without # putting package-store extraction back on the Windows critical path. - uses: actions/cache@v4 @@ -505,9 +687,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # The Windows lanes deliberately skip the store cache like the required + # windows job; an empty cache input disables setup-node's caching. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + cache: ${{ matrix.platform == 'linux' && 'pnpm' || '' }} - name: Report runner capacity run: >- @@ -515,22 +702,6 @@ jobs: console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - if: matrix.platform == 'linux' - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - if: matrix.platform == 'linux' - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -605,9 +776,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # Unlike the larger-runner suite, both platforms cache the store here: + # the consolidated topology measures cache mechanics as workload. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + cache: pnpm - name: Report runner capacity run: >- @@ -615,27 +791,6 @@ jobs: console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path (Linux) - if: matrix.platform == 'linux' - id: pnpm-store-linux - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - name: Resolve pnpm store path (Windows) - if: matrix.platform == 'windows' - id: pnpm-store-windows - shell: pwsh - run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store-linux.outputs.path || steps.pnpm-store-windows.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 if: matrix.platform == 'linux' with: @@ -693,8 +848,16 @@ jobs: # 'cancelled' and 'skipped'. all-checks-passed: name: all checks passed - # The required verdict must not add a separate standard-hosted billing dependency. - runs-on: dsh-enterprise-ubuntu-latest-32core-test + # This bookkeeping-only verdict must not depend on custom-pool + # provisioning (master's #734 moved it to ubuntu-latest) — and under + # failover it follows the same selector as the worker jobs it + # aggregates, so a standard-hosted outage cannot strand the + # branch-protection verdict either (observed 2026-07-27). + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'ubuntu-latest' }} needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows] if: always() && github.event_name == 'pull_request' steps: diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index e1ad20997d..281e931c50 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -33,23 +33,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3d1bba6c17..c445034a8c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -61,23 +61,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-24-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml index d198abf5b5..1306754d4c 100644 --- a/.github/workflows/pi-ai-provider-e2e.yml +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -27,23 +27,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-24-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 51dfe06f1c..0d14914bfd 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -53,13 +53,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - name: Enable corepack (pnpm) - run: corepack enable - - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index c488fa5a91..4651b94215 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ .playwright-mcp/ +.orig \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 43b000b298..c987801c17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop - llm/ LLM seam + DeepSeek adapters (hand-rolled + pi-ai design twin) + llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools + subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools lsp/ language-server seam + local stdio provider + model-facing lsp tool @@ -59,6 +60,7 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime +pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..e6d004b85c 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -105,6 +105,10 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash-local name: '@deepseek-ai/dsh-bash-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index e0a3a51c94..f63e9489b3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 9fd1d55ab2..1ea7d9f0db 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -22,25 +22,18 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio; resolve with output + exit code. */ -function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (c: string) => { stdout += c }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete. - child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) - child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.end() +/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ +async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin], { + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) { + throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index 6f4085da44..f53777fff8 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -144,7 +144,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s "errorSubRow": true, "parentRow": "CodeRead the notes files and summarize", "subRows": [ - "$List notes", + "BashList notes", "Readnotes/demo.txt", "Readnotes/missing.txt", ], @@ -213,9 +213,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a }).toMatchInlineSnapshot(` { "subCells": [ - "#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s", - "#54Subread · {"path":"notes/demo.txt"}+0.8s", - "#55Subread · {"path":"notes/missing.txt"}+0.8s", + "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s", + "#52Subread · {"path":"notes/demo.txt"}+0.8s", + "#53Subread · {"path":"notes/missing.txt"}+0.8s", ], } `) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 34d0e5f123..d57b934068 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -17,7 +17,7 @@ // the open llm seam post-boot with installLlmReplay on the settled root ctx // (the plugin-row path discards the ReplayHandle; the direct install keeps // assertConsumed for the teardown fixture-consumption check). -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -64,21 +64,11 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a -// catch-all would leave resolveModelContext unroutable and compact-basic's +// catch-all would leave resolveModelInfo unroutable and compact-basic's // post-step pressure check would warn every step). The published // contextWindow keeps that pressure path provably inert for small fixtures. const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] -/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */ -function loadRootEnv(): void { - const envPath = join(REPO_ROOT, '.env') - if (!existsSync(envPath)) return - for (const line of readFileSync(envPath, 'utf8').split('\n')) { - const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim()) - if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2] - } -} - /** A booted web scaffold: real composition, mode-selected model backend, temp world. */ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ @@ -142,7 +132,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveReady, reject) => { let out = '' diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts new file mode 100644 index 0000000000..ef710129d8 --- /dev/null +++ b/apps/web/tests/todo-display.snapshot.ts @@ -0,0 +1,191 @@ +// @vitest-environment jsdom +// Todo display snapshot over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session and pins the todo_write turn's two +// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary +// derived from the call args) and the TodoPanel plan strip riding the +// 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded +// by the tail history page), including the collapse interaction. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against the populated fixture branch. */ +function boot(): void { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Collapse decorative whitespace while preserving the text a user sees. */ +function visibleText(element: Element): string { + return (element.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + // Anchor on the expandable Workspace group row: the title and the blank + // session row can both read "fixture", and the session-count meta shifts + // when a blank session joins the group. + const group = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group === null || group === undefined) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull() + }, { timeout: 10_000 }) +} + +it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => { + boot() + await openFixtureSession() + + const row = document.querySelector('[data-sample="todo-row"]') + if (row === null) throw new Error('todo row missing') + const panel = document.querySelector('[data-testid="todo-panel"]') + if (panel === null) throw new Error('todo panel missing from the input dock') + + // Header spans are adjacent inline nodes; textContent joins "To-dos" + + // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node). + expect({ + row: visibleText(row), + rowState: row.getAttribute('data-state'), + panelHeader: visibleText(panel.querySelector('button') ?? panel), + panelItems: [...panel.querySelectorAll('li')].map(item => ({ + status: item.getAttribute('data-status'), + text: visibleText(item), + })), + }).toMatchInlineSnapshot(` + { + "panelHeader": "To-dos1/3 tasks · 1 in progress", + "panelItems": [ + { + "status": "completed", + "text": "梳理需求", + }, + { + "status": "in_progress", + "text": "实现 fixture 样本", + }, + { + "status": "pending", + "text": "浏览器验收", + }, + ], + "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", + "rowState": "ok", + } + `) +}) + +it('collapses the plan strip to the count summary and restores it', async () => { + boot() + await openFixtureSession() + + const panel = document.querySelector('[data-testid="todo-panel"]') + if (panel === null) throw new Error('todo panel missing from the input dock') + const header = panel.querySelector('button') + if (header === null) throw new Error('todo panel header missing') + + fireEvent.click(header) + expect({ + collapsedHeader: visibleText(header), + listGone: panel.querySelector('ul') === null, + }).toMatchInlineSnapshot(` + { + "collapsedHeader": "To-dos1/3 tasks · 1 in progress", + "listGone": true, + } + `) + + fireEvent.click(header) + expect(panel.querySelectorAll('li')).toHaveLength(3) +}) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a19211c6bf..98a2338064 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -5,12 +5,13 @@ // calls: workspace.create/rename are host RPCs with no model involvement, // and the one session row the flat/hover scenarios need comes from a seeded // fixture (the seeded-history seed reused verbatim — no new recording). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -105,6 +106,203 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete')) + const slotConsoleErrors: string[] = [] + const transientSlotErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + slotConsoleErrors.push(message.text()) + } + }) + await page.exposeFunction('recordDshSlotError', (key: string) => { + if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key) + }) + await page.evaluate(() => { + const target = window as unknown as { recordDshSlotError(key: string): Promise } + const seen = new Set() + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error]')) { + const key = node.dataset.slotError ?? '' + if (!seen.has(key)) { + seen.add(key) + void target.recordDshSlotError(key) + } + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) + // Register the scaffold's existing project directory through the real UI. + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) + await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) + await useFolder.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0) + + const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) + if (workspace === undefined) throw new Error('GUI did not register the existing project directory') + await workspace.attachSession(SessionId(SEED_ID)) + const header = (await scaffold.ctx.sessionPersistence.list()) + .find(candidate => candidate.id === SEED_ID) + if (header === undefined) throw new Error('seeded Session log disappeared before deletion') + const logLocation = scaffold.ctx.sessionPersistence.locate(header) + if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path') + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + + // Open the seeded (first/accounted) Session so deletion must preserve the + // current selection while it moves into Ungrouped. + const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first() + await groupRow.waitFor({ timeout: 10_000 }) + const groupSection = groupRow.locator('..') + if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click() + await expect.poll( + () => groupSection.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBeGreaterThanOrEqual(2) + const seededRow = groupSection.locator('[role="treeitem"]').nth(1) + await seededRow.click() + await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true') + + await groupRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + const dialog = page.getByRole('dialog', { name: 'Delete workspace' }) + await dialog.waitFor({ timeout: 10_000 }) + const copy = await dialog.textContent() + expect(copy).toContain('workspace list') + expect(copy).toContain('folder and session logs will be kept') + expect(copy).toContain('sessions will appear under Ungrouped') + await dialog.getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) + + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + await expect.poll( + () => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(), + { timeout: 10_000 }, + ).toBe(0) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + + // Re-registering the exact deleted path immediately, without a reload, is + // a supported reversible flow. It creates a fresh Workspace id without + // re-adopting the retained Session. + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) + await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) + await reuseFolder.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0) + const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) + expect(reregistered?.id).toBeDefined() + expect(reregistered?.id).not.toBe(workspace.id) + expect(reregistered?.sessionIds).toEqual([]) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(1) + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + + // Restore the deleted-registry state so reload still verifies deletion + // persistence independently of the successful re-registration above. + if (reregistered === undefined) throw new Error('same-path re-registration did not materialize') + await scaffold.ctx.workspace.delete(reregistered.id) + await expect.poll( + () => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(), + { timeout: 10_000 }, + ).toBe(0) + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 15_000 }, + ).toBe(1) + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + + expect(transientSlotErrors).toEqual([]) + expect(slotConsoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it('reuses a deleted title for a different new directory without any transient error surface', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title')) + const title = 'same-name' + const oldPath = join(scaffold.workspaceCwd, 'adopted', title) + await mkdir(oldPath, { recursive: true }) + const transientErrors: string[] = [] + const consoleErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => { + if (!transientErrors.includes(message)) transientErrors.push(message) + }) + await page.evaluate(() => { + const target = window as unknown as { + recordDshTransientWorkspaceError(message: string): Promise + } + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error], [role="alert"]')) { + const message = node.dataset.slotError ?? node.textContent?.trim() ?? '' + if (message !== '') void target.recordDshTransientWorkspaceError(message) + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const adopt = page.getByRole('dialog', { name: 'Use an existing folder' }) + await adopt.getByLabel('Existing folder path').fill(oldPath) + await adopt.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0) + const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath) + if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') + + const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first() + await oldRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + await page.getByRole('dialog', { name: 'Delete workspace' }) + .getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() + const create = page.getByRole('dialog', { name: 'Create a new workspace' }) + await create.getByLabel('New workspace name').fill(title) + await create.getByRole('button', { name: 'Create workspace' }).click() + await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0) + const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title) + expect(fresh?.id).toBeDefined() + expect(fresh?.id).not.toBe(oldWorkspace.id) + expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title)) + expect(transientErrors).toEqual([]) + expect(consoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('switches to the flat "In one list" view and persists the preference', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat')) // Grouped default: workspace group rows render (the seeded session sits @@ -134,12 +332,20 @@ describe('web e2e: workspace management (create / rename / flat view / hover car onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) // Expand Ungrouped to reveal the seeded session row, then dwell on it // (the card opens after a 500ms hover delay, portaled to body). - await page.getByText('Ungrouped', { exact: true }).click() - // A cold summary carries no durable title, so the row falls back to a - // cwd-derived display title — anchored on the run-local workspace-root - // basename rather than a literal. - const wsBase = scaffold.workspaceCwd.split('/').pop()! - const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first() + const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + // Initial-current auto-expansion can race this following test's gesture; + // converge on expanded rather than assuming which update wins first. + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await page.getByText('Ungrouped', { exact: true }).click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + // The only visible child is the non-blank persisted Session; the blank + // Session created while adopting the Workspace remains hidden. + const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.hover() // Card content: the full title plus the Idle status line (display-only diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1798b46908..2f273b2d30 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -1,6 +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 -architecture.md: b426891c0483f42a64b597632cf1871aff79ca2d -architecture.zh.md: 13feefb6854e79ddee38602902d325a789fd7744 +# pnpm run verify-translation-pairing --write docs/architecture.md +architecture.md: d80eb14c4c703f99d3401e4f69958e78998b8bfa +architecture.zh.md: b392b519310389a1c623b81b797d4c32453a940c diff --git a/docs/architecture.md b/docs/architecture.md index b426891c04..d80eb14c4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,6 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | @@ -91,7 +92,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) + agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -125,7 +126,7 @@ Pruning precedes summaries; overflow retries require durable progress. Bounded r Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`. Cancellation and disposal beat recovery; the turn signal also cancels asynchronous model-capability preparation before any request header is committed, and undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). @@ -177,7 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register an adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | -| Add shell execution | implement and register a `ctx.bash` backend | +| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.subprocess`) | | Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 13feefb685..b392b51931 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,6 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | @@ -91,7 +92,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) + agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -125,7 +126,7 @@ forever: 适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。 -其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`。取消和资源释放均优先于恢复;轮次信号还会在提交任何请求头之前取消异步模型能力准备,尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 @@ -177,7 +178,7 @@ forever: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | | 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | -| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端 | +| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.subprocess` 生成进程) | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2da9652c59..f1e473767d 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -82,10 +82,15 @@ flowchart LR pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] svc_goals["ctx.goals
    Same-session goal domain"] - pkg_bash["bash"] - svc_bash["ctx.bash
    Bash executor seam"] + pkg_subprocess["subprocess"] + svc_subprocess["ctx.subprocess
    Subprocess seam"] + pkg_subprocess_local["subprocess-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_lsp_local["lsp-local"] + pkg_subagent_acp["subagent-acp"] + pkg_bash["bash"] + svc_bash["ctx.bash
    Bash executor seam"] svc_bashEnv["ctx.bashEnv
    Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty
    Persistent PTY session registry"] @@ -113,7 +118,6 @@ flowchart LR svc_subagents["ctx.subagents
    Subagent provider registry"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] - pkg_subagent_acp["subagent-acp"] pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
    Background task registry"] @@ -191,6 +195,8 @@ flowchart LR pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents + pkg_subprocess --> svc_subprocess + pkg_subprocess_local --> svc_subprocess pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks @@ -263,6 +269,10 @@ flowchart LR svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent + svc_subprocess --> pkg_bash_local + svc_subprocess --> pkg_bash_sandbox + svc_subprocess --> pkg_lsp_local + svc_subprocess --> pkg_subagent_acp svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -317,6 +327,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a31c353fd..47a8acfd30 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,6 +192,8 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examp ## `@deepseek-ai/dsh-bash-local` +Requires: `subprocess` + ```ts config-catalog /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -210,11 +212,11 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` -Requires: `sandbox` · `sandboxPolicy` +Requires: `subprocess` · `sandbox` · `sandboxPolicy` ```ts config-catalog /** @@ -565,18 +567,19 @@ Requires: `llm` /** * Plugin config, validated by the same-named schemastery schema. Every field * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), and omitted - * thinking fields send nothing on the wire, so the provider default applies. + * missing API key fails plugin load, not the first call), omitted thinking + * mode uses the provider default, and omitted reasoning effort resolves to + * `high`. */ export interface Config { /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ apiKey?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Thinking-mode default for every request (provider default: enabled). */ + /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Thinking effort (only meaningful with thinking enabled). */ - reasoningEffort?: 'high' | 'max' + /** Default thinking effort (default `high`); `off` disables thinking per request. */ + reasoningEffort?: 'off' | 'high' | 'max' /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ @@ -585,7 +588,7 @@ export interface Config { streamIdleTimeoutMs?: number } -/** One optional model entry advertised by the hand-written adapter. */ +/** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ id: string @@ -598,7 +601,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -622,7 +625,7 @@ export interface PiAiProviderProfile { /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ - reasoning?: ThinkingLevel + reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets /** Prompt-cache retention preference. */ @@ -638,7 +641,7 @@ export interface PiAiProviderProfile { } ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts) @@ -688,7 +691,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:590`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:598`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -714,7 +717,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src ## `@deepseek-ai/dsh-lsp-local` -Requires: `lsp` +Requires: `lsp` · `subprocess` ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -750,7 +753,7 @@ export interface LspLocalServerConfig { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:87`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` @@ -1283,7 +1286,7 @@ Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/ ## `@deepseek-ai/dsh-subagent-acp` -Requires: `subagents` +Requires: `subagents` · `subprocess` ```ts config-catalog /** Config: how to spawn and drive the child ACP agent process. */ @@ -1656,7 +1659,7 @@ Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tas Requires: `tools` · `web` · `systemPrompt` ```ts config-catalog -/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */ +/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */ export interface Config { /** Register `web_search`. Defaults to true. */ search?: boolean @@ -1668,10 +1671,12 @@ export interface Config { fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ searchTimeoutMs?: number + /** Cap on source characters converted and complete `web_fetch` output characters. Defaults to 200000. */ + fetchMaxOutputChars?: number } ``` -Source: [`packages/web/tool-web/src/index.ts:29`](../packages/web/tool-web/src/index.ts) +Source: [`packages/web/tool-web/src/index.ts:35`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` @@ -1781,7 +1786,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -2072,6 +2077,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) @@ -2091,6 +2097,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) +- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts)) @@ -2118,6 +2125,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) -- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml index 497ae08c32..c37bf85c1d 100644 --- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -1,6 +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 -adding-an-llm-adapter.md: f20442b8c2ce823452a3ea13409f202185d12d04 -adding-an-llm-adapter.zh.md: 2864dd1e18742c7449e24f22504a5a38976ab450 +# pnpm run verify-translation-pairing --write docs/cookbook/adding-an-llm-adapter.md +adding-an-llm-adapter.md: a7f9dced70041653a0cb815147a07b6386d79e3e +adding-an-llm-adapter.zh.md: 3515927585201326b713bb03cd863886ce7846bd diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index f20442b8c2..a7f9dced70 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -2,7 +2,7 @@ English | [中文](adding-an-llm-adapter.zh.md) -How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. +How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (direct HTTP, SSE framed by `eventsource-parser`) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. ## The shape @@ -32,7 +32,7 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl - A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it. - If the provider requires response ids, signatures, or other native metadata on follow-up calls, emit the minimal lossless-JSON projection as `finish.replayState`. Validate it when rebuilding history. `LlmService` passes it only when the historical provider route and target provider route are currently owned by the exact same adapter instance; your adapter decides whether same-model, cross-model, or cross-provider restoration is legal. Never infer native replay from provider/model names alone when state is absent. -Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral. +Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list, including an adapter-defined `off` when supported, without exposing final wire spellings or clamping unsupported values; an id need not equal its wire representation. ## Structure that worked diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md index 2864dd1e18..3515927585 100644 --- a/docs/cookbook/adding-an-llm-adapter.zh.md +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -2,7 +2,7 @@ [English](adding-an-llm-adapter.md) | 中文 -如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(手写 HTTP/SSE)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。 +如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(直接 HTTP,SSE 由 `eventsource-parser` 分帧)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。 ## 基本形态 @@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config) { - 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。 - 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。 -提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。 +提供方特有的 thinking 模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;响应传给解析器的可选 `AbortSignal`。推理强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。 ## 经验证有效的结构 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ccae4c9f9b..ef2f196dbf 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -616,7 +616,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:53`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..c91c8a11d5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -257,7 +257,7 @@ Implementations must honor these semantics: - run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult. - start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr. - BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files. -- Disposal kills all running background processes and awaits their exit. +- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload. ```ts cordis-catalog /** @@ -286,7 +286,7 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:48`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` @@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment list(): BashEnvVariableInfo[] ``` -Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md) +Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md) Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) @@ -730,33 +730,57 @@ listProviders(): LlmProviderInfo[] async listModels(provider: string): Promise /** - * Resolve context capacity from the adapter that owns one exact route. - * This query is independent of the advisory model catalog: an unlisted model - * may return metadata, while `undefined` never rejects later routing. + * Resolve and validate all metadata from the adapter that owns one exact + * route. The result is detached from adapter-owned objects; catalog + * membership remains advisory and does not control request routing. * @param provider - registered provider route to inspect. * @param model - exact model id passed to the adapter. - * @returns detached context metadata, or `undefined` when the adapter has none. + * @param signal - optional cancellation for adapter-owned asynchronous lookup. + * @returns exact model identity plus available context and reasoning metadata. */ -async resolveModelContext( provider: string, model: string, ): Promise +async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise + +/** + * Validate a conversation call config against its exact model capability and + * materialize an adapter-configured default. Unsupported explicit efforts + * reject before provider I/O; no clamping or aliasing is performed. This + * standalone query does not bind a later dispatch; use {@link prepareCall} + * when logging and streaming must share one adapter registration. + * @param config - provider/model route and optional request controls. + * @param signal - optional cancellation for adapter-owned capability lookup. + * @returns a detached config only when a default must be materialized. + */ +async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise + +/** + * Resolve one call under its current adapter registration. The returned + * one-shot handle keeps that registration across header logging and dispatch, + * so HMR cannot combine one adapter's capability result with another adapter. + * @param config - provider/model route and optional request controls. + * @param signal - optional cancellation for adapter-owned capability lookup. + * @returns a prepared config and its registration-bound stream entry point. + */ +async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for * `options.provider`. Replay state is retained only when the same adapter * instance owns its historical provider and the target provider. Final - * adapter selection, dispatch, and iteration failures retain their original - * Error identity and are tagged in a call-local scope for narrow agent-loop - * request recovery; middleware and nested-call failures remain untagged for - * the outer call. + * adapter selection remains fixed through asynchronous exact-model resolution + * and dispatch. Selection, dispatch, and iteration failures retain their + * original Error identity and are tagged in a call-local scope for narrow + * agent-loop request recovery; middleware and nested-call failures remain + * untagged for the outer call. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:177`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1342,7 +1366,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:611`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1559,6 +1583,31 @@ Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun]( Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) +## `ctx.subprocess` — `SubprocessService` (abstract seam) + +Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. +- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. +- Disposal of the service terminates all still-running managed processes and awaits their exit. + +```ts cordis-catalog +/** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. + * @returns the live process handle (streams/readers, signalling, outcome promise). + */ +abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle +``` + +Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) + +Source: [`packages/subprocess/subprocess/src/index.ts:88`](../../packages/subprocess/subprocess/src/index.ts) + ## `ctx.systemPrompt` — `SystemPrompt` Registry service for the prompt inputs assembled before each model step. @@ -1878,7 +1927,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` @@ -2015,6 +2064,16 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] +/** + * Delete one workspace registration while retaining its directory and every + * session log. The durable order is updated before the table deletion; a + * failed table write restores the prior order and keeps the entity + * published. Unknown ids are an idempotent no-op for domain callers. + * @param id - Workspace registration to remove. + * @returns `true` when a record was deleted, `false` when it was unknown. + */ +delete(id: WorkspaceId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 98855cdc0c..9d261fe939 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.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 -bash.md: 35cf2061588907dde41123efb01e453eb9cc929d -bash.zh.md: 0cfeb9e1a858f7057e720215c41a757588751122 +bash.md: 3747244662301a256e12037ea67c21017b5ac2c5 +bash.zh.md: 9927aa8d51ee410d70bed7a2d00e40061b499e15 diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 35cf206158..3747244662 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -2,23 +2,13 @@ English | [中文](bash.zh.md) -The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. +The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md). Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Managed shell environment namespace -`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the subprocess service removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## Request vs. spec: the `resolve()` split @@ -56,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -95,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined @@ -145,19 +136,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` @@ -192,8 +171,9 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the subprocess service's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -238,4 +218,4 @@ interface BashProcessRead { ## The service -`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns process groups, timeout/abort handling, bounded collectors, spill files, credential scrubbing, and disposal quiescence. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). +`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 0cfeb9e1a8..9927aa8d51 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,23 +2,13 @@ [English](bash.md) | 中文 -bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](subprocess.md)之后。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## 受管 shell 环境命名空间 -`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。 - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 请求与规格:`resolve()` 拆分 @@ -56,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -95,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined @@ -145,19 +136,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` @@ -192,8 +171,9 @@ interface BashSandboxInfo { ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the subprocess service's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -238,4 +218,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose(资源释放)后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](subprocess.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 47310dd09b..99db2eca3e 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -1,6 +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 -core.md: 781267cccdb5bbda33e5be6a9e807fdbe47dbc83 -core.zh.md: d0f67983b98b0cf679a8e599a5f8ab3c64490dd0 +# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md +core.md: 1fb3288a6d01860220191f0ee1f914804dd2b33e +core.zh.md: 74ddc5f935c8138a7fdda601650f08702dae34d3 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 781267cccd..1fb3288a6d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -31,6 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | +| [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | | [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | @@ -199,7 +200,7 @@ interface LlmModelInfo { } ``` -Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route. +Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution. ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -209,12 +210,56 @@ interface LlmModelContext { } ``` +Reasoning effort is another exact-route capability. The core brands identifiers but does not enumerate their values; each adapter owns the ordered set, display names, and optional deployment default. + +```ts type-equiv +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +type ReasoningEffortId = Branded<'ReasoningEffortId'> +``` + +```ts type-equiv +/** Display metadata for one adapter-owned reasoning effort. */ +interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} +``` + +```ts type-equiv +/** Selectable reasoning efforts for one exact provider/model route. */ +interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** Exact-route model metadata resolved by its owning adapter. */ +interface LlmResolvedModelInfo extends LlmModelInfo { + /** Provider-owned context capacity when known. */ + context?: LlmModelContext + /** Adapter-owned selectable reasoning levels when exposed. */ + reasoning?: LlmModelReasoningInfo +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as @@ -291,21 +336,23 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. +FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). ```ts type-equiv /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d0f67983b9..74ddc5f935 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -31,6 +31,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | +| [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | | [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 | | [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 | | [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | @@ -205,7 +206,7 @@ interface LlmModelInfo { } ``` -对正确性敏感的模型容量与参考目录分开查询,并归服务该确切路由的适配器所有。 +对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。 ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -215,12 +216,56 @@ interface LlmModelContext { } ``` +推理强度是另一项针对确切路由的能力。核心为标识符添加品牌类型,但不枚举其值;有序集合、展示名称和可选的部署默认值均由各适配器持有。 + +```ts type-equiv +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +type ReasoningEffortId = Branded<'ReasoningEffortId'> +``` + +```ts type-equiv +/** Display metadata for one adapter-owned reasoning effort. */ +interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} +``` + +```ts type-equiv +/** Selectable reasoning efforts for one exact provider/model route. */ +interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** Exact-route model metadata resolved by its owning adapter. */ +interface LlmResolvedModelInfo extends LlmModelInfo { + /** Provider-owned context capacity when known. */ + context?: LlmModelContext + /** Adapter-owned selectable reasoning levels when exposed. */ + reasoning?: LlmModelReasoningInfo +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as @@ -297,21 +342,23 @@ interface ToolSchema { 循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 +FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 ```ts type-equiv /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 5de51d2794..3d35da5691 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -1,6 +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 -llm-streaming.md: fb97e74a9ec01e62ba940295112fb157cc73bd1d -llm-streaming.zh.md: fda59a64aeef1c037372d69dbc15ee0de48de222 +# pnpm run verify-translation-pairing --write docs/core-data-structures/llm-streaming.md +llm-streaming.md: 9151ba569af6b640144c1850d616b6f762b68a8b +llm-streaming.zh.md: ae4a6843b9bf4f09169b17271c7edea76f8f2051 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index fb97e74a9e..9151ba569a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -67,7 +67,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. -This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. +This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. ## `AppIdentity` — app attribution @@ -157,14 +157,30 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). + +```ts type-equiv +/** One model call whose config and adapter registration were resolved together. */ +interface PreparedLlmCall { + /** Detached, deep-frozen config with any adapter-owned default materialized. */ + readonly config: LlmCallConfig + /** + * Dispatch this call once through the registration captured during + * preparation. The request's call-config fields must match {@link config}; + * reuse or mismatch fails with `INVALID_PREPARED_CALL`. + * @param options - fully assembled request carrying the prepared config. + * @returns the chunk stream, including the `llm/stream` waterfall. + */ + stream(options: GenerateOptions): AsyncIterable +} +``` ```ts public-api /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ declare abstract class LlmAdapter { /** @@ -182,16 +198,19 @@ declare abstract class LlmAdapter { */ listModels(_provider: string): Promise; /** - * Resolve context capacity for one model accepted by this adapter. Absence - * means the adapter does not know the capacity, not that routing is invalid. - * @param _provider - one provider route owned by this adapter. - * @param _model - exact model id passed to {@link GenerateOptions.model}. - * @returns provider-owned context metadata, or `undefined` when unavailable. + * Resolve all metadata available for one exact model. This query is + * independent of the advisory catalog and does not validate request routing. + * @param provider - one provider route owned by this adapter. + * @param model - exact model id passed to {@link GenerateOptions.model}. + * @param _signal - cancellation for this exact-model lookup; asynchronous + * implementations must settle promptly after it aborts. + * @returns provider/model identity plus any context and reasoning metadata. */ - resolveModelContext( - _provider: string, - _model: string, - ): Promise; + resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index fda59a64ae..ae4a6843b9 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -67,7 +67,7 @@ interface LlmFailure { - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 - **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 -该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE(Server-Sent Events))和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 +该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(直接 fetch,SSE(Server-Sent Events)分帧经由 `eventsource-parser`)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 ## `AppIdentity`:应用归属 @@ -157,14 +157,30 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 + +```ts type-equiv +/** One model call whose config and adapter registration were resolved together. */ +interface PreparedLlmCall { + /** Detached, deep-frozen config with any adapter-owned default materialized. */ + readonly config: LlmCallConfig + /** + * Dispatch this call once through the registration captured during + * preparation. The request's call-config fields must match {@link config}; + * reuse or mismatch fails with `INVALID_PREPARED_CALL`. + * @param options - fully assembled request carrying the prepared config. + * @returns the chunk stream, including the `llm/stream` waterfall. + */ + stream(options: GenerateOptions): AsyncIterable +} +``` ```ts public-api /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ declare abstract class LlmAdapter { /** @@ -182,16 +198,19 @@ declare abstract class LlmAdapter { */ listModels(_provider: string): Promise; /** - * Resolve context capacity for one model accepted by this adapter. Absence - * means the adapter does not know the capacity, not that routing is invalid. - * @param _provider - one provider route owned by this adapter. - * @param _model - exact model id passed to {@link GenerateOptions.model}. - * @returns provider-owned context metadata, or `undefined` when unavailable. + * Resolve all metadata available for one exact model. This query is + * independent of the advisory catalog and does not validate request routing. + * @param provider - one provider route owned by this adapter. + * @param model - exact model id passed to {@link GenerateOptions.model}. + * @param _signal - cancellation for this exact-model lookup; asynchronous + * implementations must settle promptly after it aborts. + * @returns provider/model identity plus any context and reasoning metadata. */ - resolveModelContext( - _provider: string, - _model: string, - ): Promise; + resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 38ca48f9a0..1797724b9d 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -1,6 +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 -session.md: 2cbbac8042d04522fea0b1ed7a66c503e4b63f4e -session.zh.md: e932c8f99f684f1b8985b006ab4ddb145db966bd +# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md +session.md: 59bcf027c3ec728c5583478c7d17bd6eef2dc2fa +session.zh.md: 01b6f080b5fcf7b83ca46b99a8d80113a6ebe8bf diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 2cbbac8042..59bcf027c3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -175,7 +175,7 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt * canonical empty optional fields are absent. */ interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index e932c8f99f..01b6f080b5 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -177,7 +177,7 @@ interface TodoItem { * canonical empty optional fields are absent. */ interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml new file mode 100644 index 0000000000..b85701557b --- /dev/null +++ b/docs/core-data-structures/subprocess.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 +subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 +subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md new file mode 100644 index 0000000000..922e7ad0ee --- /dev/null +++ b/docs/core-data-structures/subprocess.md @@ -0,0 +1,239 @@ +# Subprocess + +English | [中文](subprocess.zh.md) + +The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams and out-of-process backends — the [bash executor family](bash.md) (collect-mode batch output), the LSP host (piped protocol streams + a collected stderr tail), and the ACP subagent backend (piped protocol streams + inherited stderr). This seam owns the managed `DSH_*` environment namespace, the shared credential scrub (`scrubbedParentEnv`), and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports the vocabulary so bash consumers keep one import root. + +Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## Managed environment namespace and captured output + +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## Node-shaped stdio dispositions + +Each stream's disposition is explicit, chosen per consumer: raw pipes for protocol framing (LSP JSON-RPC, ACP ndjson), inherit for pass-through diagnostics, and collect mode for bounded batch output — with the spill file optional, so a diagnostic tail (a language server's stderr) buffers without leaving files behind. + +```ts type-equiv +/** + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## The fully-explicit spawn spec + +The seam applies no defaults: every disposition, limit, and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). + */ +interface SubprocessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ + graceMs: number + /** + * Abort signal — starts the terminate escalation on the process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. + */ + env?: Record | undefined +} +``` + +## Handles: streams, readers, and tree-scoped termination + +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL, and `waitForExit()` observes the whole tree — enough for a consumer to build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). + +```ts type-equiv +/** + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. + */ +interface SubprocessHandle { + /** Process id (tree root); -1 when the spawn itself failed. */ + readonly pid: number + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + readonly done: Promise + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +/** + * Cursor-free incremental access to one collected output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). + */ +interface SubprocessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): SubprocessOutputRead +} +``` + +```ts type-equiv +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} +``` + +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` + + +## Outcomes carry exit facts only + +`done` reports Node's close-event vocabulary and no cause classification — the service kills on abort but never decides why (the caller reads the deadline signal it owns, e.g. the bash executor's `timedOut`/`aborted` split). Collected output stays readable through `handle.collected` after settlement, so batch and streaming callers share one access path. + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. + */ +interface SubprocessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null +} +``` + +## Service behavior + +The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached trees, per-disposition wiring, credential scrub, terminate-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md new file mode 100644 index 0000000000..5befdcdfc9 --- /dev/null +++ b/docs/core-data-structures/subprocess.zh.md @@ -0,0 +1,239 @@ +# 进程管理器 + +[English](subprocess.md) | 中文 + +进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam 与进程外后端:[bash 执行器家族](bash.md)使用收集模式(collect)的批量输出,LSP 主机使用管道化的协议流 + 收集的 stderr 尾部,ACP(Agent Client Protocol)subagent 后端则使用管道化的协议流 + inherit 的 stderr。该 seam 拥有受管的 `DSH_*` 环境命名空间、共享的凭据清除(`scrubbedParentEnv`)与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 重导出这套词汇,使 bash 消费方保持单一导入入口。 + +源码:[`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## 受管环境命名空间与捕获的输出 + +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## Node 形状的 stdio 处置方式(disposition) + +每条流的处置方式都显式给出,由各消费方自行选择:原始管道用于协议分帧(LSP JSON-RPC、ACP ndjson),inherit 用于直通的诊断输出,收集模式用于有界的批量输出;其中 spill 文件是可选的,因此诊断尾部(语言服务器的 stderr)可以只在内存中缓冲,不留下任何文件。 + +```ts type-equiv +/** + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## 完全显式的 spawn spec + +该 seam 不应用任何默认值:每项处置方式、限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). + */ +interface SubprocessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ + graceMs: number + /** + * Abort signal — starts the terminate escalation on the process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. + */ + env?: Record | undefined +} +``` + +## 句柄:流、读取器与以进程树为范围的终止 + +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树——这足以让消费方构建自己的拆卸阶梯(ACP 后端以 stdin EOF 打头的 `disposeAcpChild` 即是模板)。 + +```ts type-equiv +/** + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. + */ +interface SubprocessHandle { + /** Process id (tree root); -1 when the spawn itself failed. */ + readonly pid: number + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + readonly done: Promise + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +/** + * Cursor-free incremental access to one collected output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). + */ +interface SubprocessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): SubprocessOutputRead +} +``` + +```ts type-equiv +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} +``` + +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` + + +## 结果只承载退出事实 + +`done` 报告 Node close 事件的词汇,不携带原因分类:服务会在中止时终止进程,但绝不判定原因(调用方读取归自己所有的 deadline 信号,例如 bash 执行器的 `timedOut`/`aborted` 拆分)。收集到的输出在结算后仍可经 `handle.collected` 读取,因此批量与流式调用方共用一条访问路径。 + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. + */ +interface SubprocessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null +} +``` + +## 服务行为 + +抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程树、按处置方式接线的流、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ced431bf51..d042a9e17a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -33,7 +33,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:53`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 69bcdfd4ee..0c90a30dba 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -64,7 +64,6 @@ flowchart TD pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] pkg_subagent_spawn["subagent-spawn"] - pkg_subagent_subprocess["subagent-subprocess"] pkg_tool_subagent["tool-subagent"] end subgraph group_web["packages/web"] @@ -212,6 +211,10 @@ flowchart TD pkg_storage_json["storage-json"] pkg_storage_sqlite["storage-sqlite"] end + subgraph group_subprocess["packages/subprocess"] + pkg_subprocess["subprocess"] + pkg_subprocess_local["subprocess-local"] + end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] pkg_tasks_local["tasks-local"] @@ -232,7 +235,6 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_skill --> pkg_invariants - pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants @@ -249,6 +251,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants + pkg_subprocess --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_client_connection --> pkg_host_webserver @@ -280,6 +283,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants + pkg_helper --> pkg_subprocess pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths @@ -289,6 +293,8 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -345,6 +351,7 @@ flowchart TD pkg_agent --> pkg_system_prompt pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -399,6 +406,7 @@ flowchart TD pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm @@ -419,6 +427,7 @@ flowchart TD pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants @@ -556,6 +565,7 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_pty_local --> pkg_subprocess pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -693,6 +703,7 @@ flowchart TD pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm + pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_tools pkg_tool_pty --> pkg_agent pkg_tool_pty --> pkg_invariants @@ -719,7 +730,7 @@ flowchart TD pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subagent_subprocess + pkg_subagent_acp --> pkg_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -858,7 +869,6 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | @@ -875,6 +885,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | @@ -884,11 +895,12 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -903,7 +915,7 @@ flowchart TD | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -919,12 +931,12 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -955,7 +967,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | -| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -978,11 +990,11 @@ flowchart TD | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 07956b019b..311791e594 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1137,7 +1137,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 8735e8d5a6..402e3537a7 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.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 -llm-adapter.md: 3e83289b8072ef231f83c0fa3cfe3260547b42fa -llm-adapter.zh.md: 92fcf9b22f4bb356ada4c46f9a03ef0cc2d159da +llm-adapter.md: 3bf005647d3e7908f1d1553374266c1d6c12c2e6 +llm-adapter.zh.md: b967d112ed6879b11486ab7aedf64653089d93de diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 3e83289b80..3bf005647d 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. +`stream()` receives the exported `GenerateOptions` type. It includes the model, adapter-owned reasoning-effort id, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. + +Override `resolveModel(provider, model, signal?)` to return exact provider/model identity plus optional `context` and `reasoning` metadata in one lookup. Reasoning metadata contains ordered opaque ids and display names plus an optional configured default; preserve the adapter's authoritative selectable list, including `off` when its upstream capability API returns it, instead of promoting those values into a core enum. Honor the optional signal for asynchronous lookup so cancellation and disposal reach quiescence. The service validates the aggregate and rejects unsupported explicit efforts before `stream()`; omitting `reasoning` means that model has no selectable reasoning-effort capability. ## Register an adapter diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 92fcf9b22f..b967d112ed 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 +`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、由适配器持有的推理强度 ID、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 + +请覆写 `resolveModel(provider, model, signal?)`,在一次查询中返回确切的提供方/模型身份以及可选的 `context` 和 `reasoning` 元数据。推理元数据包含有序的不透明 ID、展示名称,以及可选的配置默认值;请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 `off`,而不要将这些值提升为核心枚举。异步查询必须响应这个可选信号,让取消和资源释放都能达到完全停稳。服务会校验聚合结果,并在调用 `stream()` 前拒绝显式指定但不受支持的推理强度;省略 `reasoning` 表示该模型没有可选的推理强度能力。 ## 注册适配器 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml index 22842fcd04..391967d91c 100644 --- a/examples/acp-agent/README.i18n.yaml +++ b/examples/acp-agent/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 -README.md: 4b3d86b00613cc7c37a8898ef3b39d40a167e66b -README.zh.md: 5bcd85f2b4ae34a11b980bf196d3401f764004d8 +README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961 +README.zh.md: 0c5f8866ea640843513fd9a4c15a17ed4db59d3b diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 4b3d86b006..0d63ec1f2d 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -9,7 +9,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, while [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. +The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK, and [`web.cordis.yml`](web.cordis.yml) adds the web seam, the local fetch provider, `web_fetch`, and a loopback HTML fixture server for the web-fetch snapshot. ## Protocol channel diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md index 5bcd85f2b4..0c5f8866ea 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -9,7 +9,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 则添加 `run_code` 及其生成的 TypeScript SDK。 +该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture 服务器。 ## 协议通道 diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 8d112f2910..b4a3236920 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -14,6 +14,8 @@ flowchart LR cfg --> plugin_acp_sandbox plugin_acp_sandbox_policy["sandbox-policy
    @deepseek-ai/dsh-sandbox-policy"] cfg --> plugin_acp_sandbox_policy + plugin_acp_subprocess["subprocess
    @deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_acp_subprocess plugin_acp_bash["bash
    @deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash plugin_acp_approval["approval
    @deepseek-ai/dsh-user-approval"] @@ -68,6 +70,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index fa7f6ad4ef..f784972429 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -33,6 +33,10 @@ mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" workspaceRoot: !!js process.cwd() +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-sandbox' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index ca766a1f8e..4fb6f9493b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -40,6 +40,7 @@ const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) +const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -109,6 +110,12 @@ const SCENARIOS: Scenario[] = [ { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, + // web_fetch markdown rendering end to end: the overlay's loopback fixture + // server supplies deterministic HTML (entities, a GFM table, nesting), the + // REAL local fetch provider retrieves it, and the tool result pins the + // turndown conversion. The fetched URL (fixed port) is part of the recorded + // transcript; replay re-executes the real fetch against the same fixture. + { name: 'web-fetch', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'web', configPath: WEB_CONFIG }, { name: 'workspace-edit', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml index 3bd5f5393c..c69cf574f7 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml @@ -10,6 +10,10 @@ - id: subagent name: '@deepseek-ai/dsh-subagent' +# The out-of-process ACP backend spawns its child through the subprocess seam. +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: subagent-acp name: '@deepseek-ai/dsh-subagent-acp' config: diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 0e42b0c6ab..3a37f3da6a 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -162,7 +162,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 285031a1de..1abccfd566 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -366,7 +366,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index fa3eba25d8..b61d7bf623 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -309,7 +309,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 487f0517b1..e51933cdf3 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 5d27e93da3..9b5925605c 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -325,7 +325,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index e9f7a2ea63..8e093db8bd 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -417,7 +417,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index dde0ba0d7a..beb93c6b53 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -492,7 +492,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/web-fetch/input.json b/examples/acp-agent/tests/snapshots/web-fetch/input.json new file mode 100644 index 0000000000..dc1993235d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl new file mode 100644 index 0000000000..47c97b1cba --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -0,0 +1,31 @@ +{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"/tmp/acp-snap-cwd-hqkZWE","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785078727718,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785078727719,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785078727721,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785078727730,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785078727731,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785078728804,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785078728805,"data":{"turn":1,"step":1,"index":0,"dt":[138,46,0,0,1,0,0,48,0,1,0,46,1,0,0,0,0,46,1,0,0,0,0,49,0,1,0,0,0,47,0,0,0,0,1,45,1,0,0,45,1,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," web","_f","etch"," tool"," exactly"," once"," to"," fetch"," http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," Let"," me"," do"," that","."]}} +{"type":"assistant/chunk","seq":50,"time":1785078729463,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":51,"time0":1785078729464,"data":{"turn":1,"step":1,"index":1,"dt":[47,0,0,0,0,46,0,0,1,46,0,0,0,0,1,46,1,0,0,0,0,45,1],"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","args":["","{","\"","url","\"",": ","\"","http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html","\"","}"]}} +{"type":"assistant/chunk","seq":75,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":76,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}}}} +{"type":"assistant/chunk","seq":77,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}}} +{"type":"assistant/chunk","seq":78,"time":1785078729804,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":79,"time":1785078729807,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} +{"type":"tool/call","seq":80,"time":1785078729809,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}} +{"type":"tool/result","seq":81,"time":1785078729843,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"step/end","seq":82,"time":1785078729847,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":83,"time":1785078729848,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":84,"time":1785078730611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":85,"time0":1785078730612,"data":{"turn":1,"step":2,"index":0,"dt":[158,54,1,0,0,36,1,47,47,46,1,0,0,47,0,0,0,0,1,46,43,1,0,0,48,0,46,0,0,0],"texts":["The"," user"," asked"," me"," to"," fetch"," the"," URL",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," I","'ve"," fetched"," it","."," Now"," I"," just"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":116,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":117,"time":1785078731236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":118,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":120,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":121,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":122,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":123,"time":1785078731283,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}},"sourceEventSeqs":[84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} +{"type":"step/end","seq":124,"time":1785078731286,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":125,"time":1785078731286,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md new file mode 100644 index 0000000000..45705db0a5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -0,0 +1,27 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json new file mode 100644 index 0000000000..70940f8907 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -0,0 +1,489 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_fetch", + "description": "Fetch the content of a specific HTTP(S) URL and return it decoded to text.", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The HTTP(S) URL to fetch." + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/web-fetch-fixture-server.mjs b/examples/acp-agent/web-fetch-fixture-server.mjs new file mode 100644 index 0000000000..1667562faf --- /dev/null +++ b/examples/acp-agent/web-fetch-fixture-server.mjs @@ -0,0 +1,55 @@ +/** + * Deterministic loopback HTTP fixture for the web-fetch snapshot scenario: a + * small HTML page (headings, named entities, a GFM table, nested formatting) + * on a fixed port, so recording and keyless replay drive the REAL + * `dsh-web-fetch-local` transport and `dsh-tool-web` markdown rendering + * without external network. The port is fixed because the fetched URL is part + * of the recorded model transcript. + */ +import { createServer } from 'node:http' + +/** Fixed loopback port the scenario prompt points `web_fetch` at. */ +const PORT = 43117 + +const PAGE = ` +Menu + +

    Café menu

    +

    Prices include service & tax — updated daily.

    +
    • Espresso
    • Flat white
    +
    DrinkPrice
    Espresso€2
    Flat white€3
    +
    + +` + +/** Cordis plugin name. */ +export const name = 'web-fetch-fixture-server' + +/** + * Start the fixture server on 127.0.0.1 and register its shutdown. + * @param ctx - Cordis context; the effect disposes the server with the fiber. + */ +export async function apply(ctx) { + const server = createServer((req, res) => { + if (req.url === '/menu.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(PAGE) + return + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('not found') + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(PORT, '127.0.0.1', () => resolve(undefined)) + }) + // The fixture must never hold the process open past protocol shutdown. + server.unref() + ctx.effect(() => async () => { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve(undefined)) + // Stop accepting first so a connection cannot arrive after the forced close. + server.closeAllConnections() + }) + }, 'web-fetch-fixture-server') +} diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml new file mode 100644 index 0000000000..015e67e221 --- /dev/null +++ b/examples/acp-agent/web.cordis.snapshot.yml @@ -0,0 +1,31 @@ +# Keyless replay counterpart to web.cordis.yml: the web stack and loopback +# fixture server stay real (the tool call re-executes the actual HTTP fetch and +# markdown rendering); only the model adapter is replaced by replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml new file mode 100644 index 0000000000..1ed0b3efba --- /dev/null +++ b/examples/acp-agent/web.cordis.yml @@ -0,0 +1,21 @@ +# Web-fetch composition for the web-fetch snapshot scenario: the web seam, the +# real local HTTP fetch provider, the model-facing web tools (fetch only, so +# the pinned header carries exactly the surface under test), and the loopback +# fixture server the scenario prompt fetches — deterministic content, no +# external network, in recording and replay alike. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 3cbcb0e571..55e7f33b2c 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_cordis_hmr plugin_cordis_llm_deepseek["llm-deepseek
    @deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_cordis_llm_deepseek + plugin_cordis_subprocess["subprocess
    @deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_cordis_subprocess plugin_cordis_bash["bash
    @deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash plugin_cordis_fs_local["fs-local
    @deepseek-ai/dsh-fs-local"] @@ -39,6 +41,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 6144d3ae4d..369886c9e5 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -25,6 +25,10 @@ # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index e467d32833..53a01260e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -10,6 +10,8 @@ flowchart LR cfg["examples/headless-agent
    cordis.yml"] plugin_headless_llm_deepseek["llm-deepseek
    @deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek + plugin_headless_subprocess["subprocess
    @deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_headless_subprocess plugin_headless_bash["bash
    @deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash plugin_headless_cli_agent["cli-agent
    @deepseek-ai/dsh-cli-demo"] @@ -54,6 +56,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 944fe7e180..896c73469b 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -19,6 +19,10 @@ - id: deepseek-v4-flash contextWindow: 128000 +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml index fc7959e25d..432b64eb9f 100644 --- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml +++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml @@ -17,6 +17,10 @@ file: !!js process.env.DSH_SNAPSHOT_FILE overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index ef84d7309f..0619f8f547 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' @@ -55,6 +56,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -114,6 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 5238e67374..0f0b02c1d9 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,8 +1,33 @@ import type { Context } from 'cordis' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + ReasoningEffortId, + type GenerateOptions, + type LlmResolvedModelInfo, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +const HIGH = ReasoningEffortId('high') +const OFF = ReasoningEffortId('off') /** Keyless headless-agent adapter: one real bash call followed by a final answer. */ class CliMockAdapter extends LlmAdapter { + override async resolveModel(provider: string, model: string): Promise { + return { + provider, + id: model, + name: model, + reasoning: { + efforts: [ + { id: OFF, name: 'Off' }, + { id: HIGH, name: 'High' }, + ], + defaultEffort: HIGH, + }, + } + } + async * stream(options: GenerateOptions): AsyncIterable { const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') if (toolResult === undefined) { @@ -34,4 +59,8 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) + ctx.on('agent/request', async (_agent, _turn, step, _config, _signal, next) => { + const config = await next() + return step === 2 ? { ...config, reasoningEffort: OFF } : config + }) } diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index bc9b71685e..d66713526e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -2,6 +2,10 @@ - id: cli-mock-llm name: '../cli-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index 91ba8a1254..a105652e9c 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -2,6 +2,10 @@ - id: time-context-mock-llm name: './time-context-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index e1edc1dadd..f0cde5c274 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -59,6 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 6852fed20b..f474cb5460 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -28,6 +28,7 @@ const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -124,6 +125,46 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('logs the model default and a dynamic next-step reasoning effort', async () => { + const result = await runLoaderSmoke({ + label: 'reasoning effort headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-reasoning-effort-', + binScript, + configPath: reasoningConfigPath, + binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'], + tsconfigPath, + }) + + expect(result.stderr).toBe('') + const headers = parseJsonl(result.stdout) + .map(record => record.event) + .filter((event): event is JsonObject => ( + event !== null + && typeof event === 'object' + && !Array.isArray(event) + && 'type' in event + && event.type === 'request/header' + )) + .map((event) => { + const data = event.data as JsonObject + return (data.header as JsonObject).config + }) + expect(headers).toMatchInlineSnapshot(` + [ + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "high", + }, + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "off", + }, + ] + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 045b9bb736..6cae860e36 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 8193973bee..c00a4119c7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index f0eb7ea6d8..b1364679f9 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -17,6 +17,10 @@ thinking: enabled reasoningEffort: max +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cb2ab9687e..0d4e4d8f2e 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) @@ -69,7 +69,9 @@ describe('jsonrpc-agent keyless smoke', () => { await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) const address = modelServer.address() if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') - const child = spawn(process.execPath, [ + // The line-predicate protocol driving below is the genuinely custom part; + // execa owns spawn, the deadline, and exit settlement around it. + const child = execa(process.execPath, [ '--import', 'tsx', binScript, @@ -77,27 +79,26 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_CWD: root, DSH_SESSION_ROOT: join(root, '.sessions'), ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), }, - stdio: ['pipe', 'pipe', 'pipe'], + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, }) const lines: string[] = [] let stdoutBuffer = '' let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdoutBuffer += chunk + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') const parts = stdoutBuffer.split('\n') stdoutBuffer = parts.pop() ?? '' lines.push(...parts) }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) try { child.stdin.write(`${JSON.stringify({ @@ -144,16 +145,8 @@ describe('jsonrpc-agent keyless smoke', () => { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) - if (child.exitCode === null) { - await new Promise((resolve, reject) => { - child.once('exit', (code) => { - if (code === 0) resolve() - else reject(new Error(`runtime exited ${code}; stderr=${stderr}`)) - }) - }) - } else { - expect(child.exitCode, stderr).toBe(0) - } + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.sessions') const files = await readdir(sessionsRoot, { recursive: true }) const log = files.find(file => file.endsWith('.jsonl.zstd')) @@ -162,14 +155,16 @@ describe('jsonrpc-agent keyless smoke', () => { expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) } finally { - if (child.exitCode === null) child.kill('SIGKILL') + // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. + child.kill('SIGKILL') + await child await new Promise(resolve => modelServer.close(() => { resolve() })) await rm(root, { recursive: true, force: true }) } }, 40_000) it('rejects an invalid max-token success env value', async () => { - const child = spawn(process.execPath, [ + const { exitCode, stdout, stderr } = await execa(process.execPath, [ '--import', 'tsx', binScript, @@ -177,26 +172,17 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const exitCode = await new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', resolve) + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc') - }, 10_000) + }, 30_000) }) diff --git a/examples/package.json b/examples/package.json index 8a81d399b8..6459c1c551 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml index 631848bb77..3d29530411 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/tui-agent/README.i18n.yaml @@ -1,6 +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 -README.md: fdf3972f98f8ff9690b71469ae415dc18d339f10 -README.zh.md: 71f7ae949d034757a20adfae2cbe566011edc584 +# pnpm run verify-translation-pairing --write examples/tui-agent/README.md +README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 +README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index fdf3972f98..ea8695d37e 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -19,7 +19,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. ### Resuming a prior session @@ -53,7 +53,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access | -| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | +| `llm-deepseek` | the default native adapter | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index 71f7ae949d..b3f6dc1853 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -19,7 +19,7 @@ pnpm run demo:tui `todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 -TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 和 Enter,或使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 +TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 ### 恢复早先的会话 @@ -53,7 +53,7 @@ pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay | 配置项 | 演示内容 | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | -| `llm-deepseek` | 通过配置提供真实 `LlmAdapter`(`!!js process.env.…` 密钥);将一行替换为 `@deepseek-ai/dsh-llm-pi-ai` 即可使用库后端对照实现 | +| `llm-deepseek` | 默认原生适配器 | | `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index fd6d163952..c6fc223113 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
    @deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_subprocess["subprocess
    @deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess plugin_tui_bash["bash
    @deepseek-ai/dsh-bash-local"] cfg --> plugin_tui_bash plugin_tui_tui_agent["tui-agent
    @deepseek-ai/dsh-tui-demo"] @@ -69,6 +71,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index e96ef680b2..7c8b03db05 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -21,6 +21,10 @@ reasoningEffort: max # Local executor for the app bundle's bash tool. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c3bdbfd1b1..d3e35b6ce5 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,6 +1,11 @@ import type { Context } from 'cordis' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` @@ -35,8 +40,28 @@ class ScriptedTuiAdapter extends LlmAdapter { ]) } - override resolveModelContext(_provider: string, _model: string): Promise { - return Promise.resolve({ contextWindow: 128_000 }) + override resolveModel( + provider: string, + model: string, + ): Promise { + return Promise.resolve({ + provider, + id: model, + name: model === 'tui-scripted-model-pro' ? 'Scripted Pro' : 'Scripted Base', + context: { contextWindow: 128_000 }, + ...model !== 'tui-scripted-model-pro' + ? {} + : { + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }, + }) } override async * stream(options: GenerateOptions): AsyncIterable { @@ -47,8 +72,12 @@ class ScriptedTuiAdapter extends LlmAdapter { for (const chunk of textChunks(TITLE_TEXT)) yield chunk return } - if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { - throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') + if ( + options.model !== 'tui-scripted-model-pro' + || !options.system?.includes('tui-scripted-model-pro') + || options.reasoningEffort !== ReasoningEffortId('max') + ) { + throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') } const lastMessage = options.messages.at(-1) const lastText = (lastMessage?.content ?? []) diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 307c5a1dfe..a32bdf987b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -4,6 +4,10 @@ - id: scripted-llm name: './tui-scripted-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 700c67f660..ea088a20f9 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` @@ -94,35 +94,32 @@ async function runPosixPtySmoke( options: TuiPtySmokeOptions, timeoutMs: number, ): Promise { - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - POSIX_PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs + 5_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve(stdout) - else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) + // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline + // only backstops a wedged python3 process itself. + const result = await execa('python3', [ + '-c', + POSIX_PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { + stdin: 'ignore', + timeout: timeoutMs + 5_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return result.stdout } async function runWindowsPtySmoke( diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 5967366c12..1cbe6c478e 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -103,7 +103,7 @@ function smoke(overrides: Partial & { label: string }): Prom // other route (see fixtures/tui-scripted-llm.ts). const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, - { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { @@ -156,6 +156,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { ], }) expect(output).toContain('I need one decision before I continue.') + expect(output).toContain('Reasoning effort: Max.') expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') expect(output).toContain('Leaving plan mode (applies from the next step).') expect(output).toContain('Default mode confirmed.') diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 1f2989f183..9636895dbe 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -8,6 +8,7 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -220,6 +221,7 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/knip.json b/knip.json index 110abb3a2b..36fbefb7d7 100644 --- a/knip.json +++ b/knip.json @@ -6,13 +6,15 @@ "ignoreBinaries": [ "bwrap", "python3", - "sandbox-exec" + "sandbox-exec", + "taskkill" ], "ignoreWorkspaces": [ "vendor/*", "python/sdk-runtime" ], "ignoreDependencies": [ + "@yarnpkg/cli-dist", "lightningcss" ], "workspaces": { @@ -262,8 +264,14 @@ ] }, "packages/session-query/session-query-sqlite": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/code-runtime/code-runtime-worker": { "entry": [ @@ -316,8 +324,14 @@ ] }, "packages/session-persistence/session-checkpoint-policy": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/paths": { "entry": [ @@ -488,15 +502,6 @@ "tests/**/*.ts" ] }, - "packages/subagent/subagent-subprocess": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/package.json b/package.json index 90194a57d3..d74a780925 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", @@ -113,8 +114,10 @@ "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", + "@yarnpkg/cli-dist": "4.17.1", "eslint": "^10.4.1", "eslint-plugin-sonarjs": "^4.1.0", + "execa": "^10.0.0", "fast-check": "^4.8.0", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 9b2c44af22..be7e7c5b67 100644 --- a/packages/README.i18n.yaml +++ b/packages/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 -README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25 -README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1 +README.md: 911f18547120eb3dbbc9e42bbcd41e3b6d518cfe +README.zh.md: d6e1f0bf9b38b40944f8e3cebea3f6d90dcaceb5 diff --git a/packages/README.md b/packages/README.md index d7427c3f98..911f185471 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,6 +13,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 6894aa7333..d6e1f0bf9b 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -13,6 +13,7 @@ | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | +| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | | [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 6f9db27161..7c222fb6f5 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/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 -README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060 -README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2 +README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b +README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db diff --git a/packages/bash/README.md b/packages/bash/README.md index 08b3627080..e60ad9b0e4 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -6,8 +6,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` | -| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | +| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index a98506a6cd..57c28b45cf 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx key | |---|---|---| -| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` | -| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) | +| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | +| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index c7e9587289..f94d04f90e 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/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 -README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c -README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1 +README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 +README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 1668f33e8a..694b7a7686 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. -The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. +The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. ## Config @@ -24,11 +24,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: -- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). +- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. ## Model Experience @@ -42,8 +42,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. -- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. -The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index 0e0a4ad41b..aa6de87df4 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c `,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。 +`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。 -包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。 +包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 ## 配置 @@ -24,11 +24,11 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 -- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 -- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。 -- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 ## 模型体验 @@ -42,8 +42,7 @@ - **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 -- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 -- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 -原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。 +凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。 diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 152c2db28b..bacc7ac92d 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,8 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 2c25701fb9..0f5a1b4e4d 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,17 +1,39 @@ /** - * Local-subprocess implementation of the bash executor seam. Each command runs - * as `bash -c` in its own process group; disposal kills and joins live groups. - * Execution policy belongs in `tools/pre-execute` or a sandboxing executor. + * Local implementation of the bash executor seam over the subprocess + * seam. Each command runs as `bash -c` in a managed process group spawned + * through `ctx.subprocess`; this executor owns command defaulting, deadlines + * and cause classification, the model-friendly terminal environment, and the + * model-facing stdout/stderr merge for background reads. Execution policy + * belongs in `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts' -import type { RunInternals, RunningBash } from './run.ts' + +/** + * Model-friendly environment overrides: disable colors, pagers, and + * interactive terminal features that would garble tool output (the same set + * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — + * merged first into the spawn's explicit env, so a trusted caller's own entry + * still wins; the subprocess service applies its credential scrub independently. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + TERM: 'dumb', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -32,6 +54,16 @@ export interface Config { /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) @@ -39,10 +71,15 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * Local bash executor with bounded output, spill files, and process-group - * `SIGTERM` to `SIGKILL` escalation. + * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and + * process-group SIGTERM→SIGKILL escalation are the subprocess service's + * mechanics; this executor supplies their configured budgets per spawn, so a + * still-running background process stays managed (killed and joined at + * composition teardown) even across an executor reload. */ export class LocalBashExecutor extends BashExecutor { + static inject = ['subprocess'] + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), @@ -52,11 +89,6 @@ export class LocalBashExecutor extends BashExecutor { graceMs: z.number().default(DEFAULT_GRACE_MS), }) - /** Live processes retained only so disposal can kill and join them. */ - private live = new Map() - /** Test seam: spill knobs forwarded to runBash. */ - internals: RunInternals = {} - /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig @@ -69,17 +101,6 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) - ctx.effect(() => async () => { - // Await closure so even a TERM-trapping child cannot outlive the fiber. - const pending: Promise[] = [] - for (const [proc, running] of this.live) { - proc.status = 'killed' - running.kill() - pending.push(proc.done) - } - this.live.clear() - await Promise.all(pending) - }, 'local bash teardown') } /** @@ -105,7 +126,7 @@ export class LocalBashExecutor extends BashExecutor { stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, - // no config default. run.ts owns the scrub and merge order. + // no config default. The subprocess service owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, @@ -116,41 +137,71 @@ export class LocalBashExecutor extends BashExecutor { } } + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + return { + argv: ['bash', '-c', spec.command], + cwd: spec.workdir, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, + graceMs: this.config.graceMs, + signal, + // One explicit env map for the seam, layered so the trusted dshEnv + // snapshot beats both the caller's env and the terminal overrides; the + // subprocess service merges the whole map after its ambient scrub. + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, + } + } + + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ + if (stdout === undefined || stderr === undefined) { + throw new Error('bash-local: subprocess implementation dropped a requested collect stream') + } + /* v8 ignore stop */ + return { stdout, stderr } + } + async run(spec: BashExecSpec): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: spec.stdoutMaxBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: d.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals).done + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut - return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs } + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } } start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: this.config.maxOutputBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: spec.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = LocalBashExecutor.collected(running) + + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } let stdoutOffset = 0 let stderrOffset = 0 @@ -165,26 +216,27 @@ export class LocalBashExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + this.onProcessDone(proc, collected.stderr.readFrom(0).text) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`)) - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) }), readOutput: (): BashProcessRead => { - const out = running.stdout.readFrom(stdoutOffset) - const err = running.stderr.readFrom(stderrOffset) + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) stdoutOffset = out.nextOffset stderrOffset = err.nextOffset + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' const delta = out.text - + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '') + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') return { delta, lossy: out.lossy || err.lossy, @@ -195,11 +247,10 @@ export class LocalBashExecutor extends BashExecutor { kill: (): boolean => { if (proc.status !== 'running') return false proc.status = 'killed' - running.kill() + running.terminate() return true }, } - this.live.set(proc, running) return proc } diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts deleted file mode 100644 index 600e920c96..0000000000 --- a/packages/bash/bash-local/src/run.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Process plumbing for the local bash executor: detached process-group spawn, - * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer - * reacts to an abort signal; the executor owns deadlines and classifies causes. - * @module dsh-bash-local/run - */ - -import { type ChildProcessByStdio, spawn } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' -import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' - -/** - * Model-friendly environment overrides: disable colors, pagers, and - * interactive terminal features that would garble tool output (the same set - * Codex hardcodes; Claude Code achieves it via TERM=dumb). - */ -export const ENV_OVERRIDES = { - NO_COLOR: '1', - TERM: 'dumb', - PAGER: 'cat', - GIT_PAGER: 'cat', -} as const - -/** - * Credential-shaped env vars are NOT forwarded to commands (the harness's - * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or - * spill files). Same default pattern as Codex's env policy; a future config - * can whitelist specific vars when a workflow genuinely needs one. - */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * Build a child environment from scrubbed ambient values, terminal overrides, - * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed - * names are removed; ordinary and managed entries reject the other channel's - * namespace before `dshEnv` merges last. - * @param extra - caller entries; `DSH_*` names are rejected. - * @param dshEnv - managed entries; non-`DSH_*` names are rejected. - * @returns the environment to hand to `spawn` for the child process. - */ -export function childEnv( - extra?: Readonly>, - dshEnv?: DshEnvironment, -): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value - } - for (const key of Object.keys(extra ?? {})) { - if (key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) - } - } - for (const key of Object.keys(dshEnv ?? {})) { - if (!key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`) - } - } - return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv } -} - -/** What to run and under which limits (resolved — no defaults in here). */ -export interface SpawnSpec { - command: string - cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ - graceMs: number - /** - * Abort signal — kills the process group when it fires. The executor owns - * timing: `run()` passes a fused timeout/cancel deadline signal (see - * `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal. - * runBash only listens and kills; it does NOT classify why (the executor - * reads the signal's reason afterward). - */ - signal?: AbortSignal | undefined - /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges); - * the model-facing `dsh-tool-bash` tool does not thread model input here. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the credential scrub and - * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`. - */ - env?: Record | undefined - /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */ - dshEnv?: DshEnvironment | undefined -} - -/** - * Raw outcome of one closed process (before result shaping). Deliberately - * carries NO timeout/cancel classification: runBash kills on abort but does not - * decide why — the executor's `run()`/`start()` reads the deadline signal it - * owns to classify `timedOut`/`aborted` (see the package README). - */ -export interface SpawnOutcome { - exitCode: number | null - signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput -} - -/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ -export interface RunInternals { - /** Directory for spill files (defaults to the OS temp dir). */ - spillDir?: string -} - -/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ -export const DEFAULT_GRACE_MS = 3_000 - -/** Default per-stream spill cap (the `maxSpillBytes` config). */ -export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 - -let spillCounter = 0 -let defaultSpillDir: string | undefined - -/** - * The default spill location: a private (0700) per-process directory under - * the OS tmpdir, created lazily. Predictable world-readable paths would let - * other local users read command output or pre-create symlinks. - */ -function privateSpillDir(): string { - defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-')) - return defaultSpillDir -} - -/** - * Collects one stream with a bounded in-memory tail. On first overflow a - * spill file is created and every chunk (including those already collected) - * is appended there while the full stream remains within `maxSpillBytes`. - * - * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the - * end of command output; the spill file covers the head. - */ -export class OutputCollector { - private chunks: Buffer[] = [] - private bytes = 0 - private dropped = false - private spillFd: number | undefined - private spillFile: string | undefined - private spillDisabled = false - /** Total bytes ever pushed (not just retained). */ - private total = 0 - - constructor( - private readonly maxBytes: number, - private readonly maxSpillBytes: number, - private readonly label: string, - private readonly spillDir: string, - ) {} - - /** - * Ingest one stream chunk, counting it toward the whole-stream total. On - * first overflow of the in-memory cap a spill file is opened and every chunk - * (already-collected ones included) is appended there from then on; the - * in-memory tail then drops whole chunks from its head (or the head of a - * single over-cap chunk) until it fits the cap again. - * @param chunk - the raw bytes from one stream 'data' event. - */ - push(chunk: Buffer): void { - this.total += chunk.length - const overflows = this.bytes + chunk.length > this.maxBytes - if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) - this.chunks.push(chunk) - this.bytes += chunk.length - while (this.bytes > this.maxBytes && this.chunks.length > 1) { - // Drop whole chunks from the head; pipe chunks are small (≤64KiB), so - // the retained tail tracks the cap closely enough for a model-facing - // truncation boundary. (length > 1 was just checked — shift() returns.) - const head = this.chunks.shift() as Buffer - this.bytes -= head.length - this.dropped = true - } - if (this.bytes > this.maxBytes && this.chunks.length === 1) { - // A single chunk larger than the cap: keep its tail. - const only = this.chunks[0] as Buffer - this.chunks[0] = only.subarray(only.length - this.maxBytes) - this.bytes = this.maxBytes - this.dropped = true - } - } - - /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ - private spillAll(chunk: Buffer): void { - if (this.total > this.maxSpillBytes) { - this.discardSpill() - return - } - if (this.spillFd === undefined) { - // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any - // existing path, symlink or not) + owner-only mode: defeats spill-path - // prediction and symlink planting in shared tmp dirs. - this.spillFile = join( - this.spillDir, - `dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, - ) - this.spillFd = openSync(this.spillFile, 'wx', 0o600) - for (const prior of this.chunks) writeSync(this.spillFd, prior) - } - writeSync(this.spillFd, chunk) - } - - /** Stop spilling and remove the file once it can no longer hold the complete stream. */ - private discardSpill(): void { - const fd = this.spillFd - const file = this.spillFile - this.spillFd = undefined - this.spillFile = undefined - this.spillDisabled = true - if (fd !== undefined) { - try { - closeSync(fd) - } catch { - // Retain the descriptor so finalize can retry the failed close. - this.spillFd = fd - } - } - if (file !== undefined) { - try { - unlinkSync(file) - } catch { - // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file. - } - } - } - - /** - * Incremental read in whole-stream byte coordinates: returns everything - * pushed since `fromByte`. When `fromByte` has already slid out of the - * in-memory tail window, the read is `lossy` — it returns the whole - * retained tail and the gap is only recoverable from the spill file. - * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). - * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created. - */ - readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } { - const windowStart = this.total - this.bytes - const buffer = Buffer.concat(this.chunks) - const lossy = fromByte < windowStart - const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart) - return { - text: slice.toString('utf8'), - nextOffset: this.total, - lossy, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } - - /** - * Close the spill file (if any) and return the final output. A failed close - * (delayed writeback fault) stops advertising the spill path — the file may - * be missing its tail — but still returns the in-memory result. - * @returns the final collected output: tail text, truncation flag, and the spill path when intact. - */ - finalize(): CollectedOutput { - if (this.spillFd !== undefined) { - try { - closeSync(this.spillFd) - } catch { - // A delayed writeback failure makes the spill unreliable; keep finalize - // total but stop advertising that file. - this.spillFile = undefined - } - this.spillFd = undefined - } - return { - text: Buffer.concat(this.chunks).toString('utf8'), - truncated: this.dropped, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } -} - -/** - * Send `sig` to a detached process group. Never throws: delivery races process - * exit and may run in a timer callback, so failures are contained and a - * non-positive pid is a no-op. - * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. - * @param sig - the signal to deliver to the whole group. - */ -export function killGroup(pid: number, sig: NodeJS.Signals): void { - if (pid <= 0) return - try { - process.kill(-pid, sig) - } catch { - // Swallow: see contract above. - } -} - -/** - * A live bash child process: the promise resolves when the process closes; - * `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group. - */ -export interface RunningBash { - /** Process id (group leader); -1 when the spawn itself failed. */ - readonly pid: number - /** stdout/stderr collectors (live — background polling reads incrementally). */ - readonly stdout: OutputCollector - readonly stderr: OutputCollector - /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void -} - -/** - * Spawn one isolated `bash -c` process group and collect its output. - * Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject. - * @param spec - fully resolved command, cwd, limits, and cancellation. - * @param internals - test-only process and spill-directory overrides. - * @returns live process handle and outcome promise. - */ -// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. -export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { - const spillDir = internals.spillDir ?? privateSpillDir() - - if (spec.signal?.aborted) { - throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) - } - - // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. - const env = childEnv(spec.env, spec.dshEnv) - const child: ChildProcessByStdio = spec.stdin !== undefined - ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) - : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) - - const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) - child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) - child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) - - let graceTimer: NodeJS.Timeout | undefined - - // Failed spawns use pid -1 so kill remains a no-op. - const pid = child.pid ?? -1 - - const kill = (): void => { - if (graceTimer !== undefined) return // escalation already in flight - killGroup(pid, 'SIGTERM') - graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) - } - - // The executor owns timeout classification; this layer only reacts to abort. - const onAbort = (): void => { kill() } - spec.signal?.addEventListener('abort', onAbort, { once: true }) - - // Stdin writes are best-effort; process exit and captured output remain authoritative. - if (child.stdin !== null) { - child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(spec.stdin) - } - - const done = new Promise((resolve, reject) => { - let settled = false - let pipeDrainTimer: NodeJS.Timeout | undefined - const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { - if (settled) return - settled = true - child.stdout.destroy() - child.stderr.destroy() - cleanup() - resolve({ - exitCode, - signal, - stdout: stdout.finalize(), - stderr: stderr.finalize(), - }) - } - child.on('error', (error) => { - // No meaningful close outcome follows a spawn failure. - settled = true - cleanup() - reject(error) - }) - child.on('exit', (exitCode, signal) => { - pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) - }) - child.on('close', settle) - function cleanup(): void { - if (graceTimer !== undefined) clearTimeout(graceTimer) - if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) - spec.signal?.removeEventListener('abort', onAbort) - } - }) - - return { pid, stdout, stderr, done, kill } -} diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 2e24addb3b..1395b48958 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,16 +4,18 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } // A short kill grace via the REAL config path, so escalation tests stay fast. await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } return { ctx, bash } } @@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => { }) }) -describe('LocalBashExecutor disposal', () => { - it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => { +describe('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } // The child prints its own pid ($$ = the detached bash group leader) so // the test can probe liveness through the public read surface alone. - const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' })) + const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' })) const pid = Number((await readUntil(proc, '\n')).trim()) expect(Number.isInteger(pid) && pid > 0).toBe(true) - await fiber.dispose() - // Disposal itself waited: the pid must already be gone, no grace left — - // even for a TERM-trapping child held until the SIGKILL escalation landed. + // Executor reload/disposal leaves background work running — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Service disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() expect(() => process.kill(pid, 0)).toThrow() - expect(proc.status).toBe('killed') await proc.done + expect(proc.status).toBe('killed') }) - it('settled processes already left the live map: dispose does not touch them', async () => { + it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } const finished = bash.start(bash.resolve({ command: 'echo done' })) await finished.done expect(finished.status).toBe('completed') - const running = bash.start(bash.resolve({ command: 'sleep 60' })) + const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' })) + await readUntil(trapping, 'armed') - await fiber.dispose() - // The teardown marks every LIVE entry killed; a settled process had - // already left the map, so its status stays completed. + await managerFiber.dispose() + // A settled process was untouched; the live one died by escalation. expect(finished.status).toBe('completed') - expect(running.status).toBe('killed') - await running.done - expect(running.signal).toBe('SIGTERM') + await trapping.done + expect(trapping.status).toBe('killed') + expect(trapping.signal).toBe('SIGKILL') }) }) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts deleted file mode 100644 index 91afd1aede..0000000000 --- a/packages/bash/bash-local/tests/run.spec.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-bash' -import { killGroup, OutputCollector, runBash } from '../src/run.ts' -import type { RunningBash } from '../src/run.ts' - -const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ - failNextClose: { value: false }, - failNextUnlink: { value: false }, -})) -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - closeSync(fd: number): void { - if (failNextClose.value) { - failNextClose.value = false - throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' }) - } - actual.closeSync(fd) - }, - unlinkSync(path: Parameters[0]): void { - if (failNextUnlink.value) { - failNextUnlink.value = false - throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' }) - } - actual.unlinkSync(path) - }, - } -}) - -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-')) - -function spec(command: string, overrides: Partial[0]> = {}) { - return { - command, - cwd: process.cwd(), - stdoutMaxBytes: 64_000, - stderrMaxBytes: 64_000, - maxSpillBytes: 64 * 1024 * 1024, - graceMs: 3_000, - ...overrides, - } -} - -/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */ -async function waitGone(pid: number, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - process.kill(pid, 0) - } catch { - return - } - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) -} - -async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (running.stdout.readFrom(0).text.includes(expected)) return - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) -} - -async function waitForPidFile(path: string, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - const pid = Number(readFileSync(path, 'utf8').trim()) - if (Number.isSafeInteger(pid) && pid > 0) return pid - } catch { - // The child shell has not written the pid file yet. - } - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) -} - -describe('runBash', () => { - it('captures stdout on success', async () => { - const result = await runBash(spec('echo hello')).done - expect(result.exitCode).toBe(0) - expect(result.signal).toBeNull() - expect(result.stdout.text).toBe('hello\n') - expect(result.stdout.truncated).toBe(false) - expect(result.stderr.text).toBe('') - }) - - it('captures stderr separately', async () => { - const result = await runBash(spec('echo oops >&2')).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('') - expect(result.stderr.text).toBe('oops\n') - }) - - it('captures both streams', async () => { - const result = await runBash(spec('echo out; echo err >&2')).done - expect(result.stdout.text).toBe('out\n') - expect(result.stderr.text).toBe('err\n') - }) - - it('reports non-zero exit codes', async () => { - const result = await runBash(spec('exit 42')).done - expect(result.exitCode).toBe(42) - expect(result.signal).toBeNull() - }) - - it('applies model-friendly env overrides', async () => { - const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done - expect(result.stdout.text).toBe('1/dumb/cat\n') - }) - - it('runs in the requested cwd', async () => { - const result = await runBash(spec('pwd', { cwd: '/tmp' })).done - expect(result.stdout.text.trim()).toMatch(/\/tmp$/) - }) - - it('kills the process group with SIGTERM when the signal fires', async () => { - // runBash owns no timer: it kills on abort. The executor drives the timeout - // by firing this signal via a deadline (see executor.spec.ts); here we - // assert the kill itself lands as SIGTERM. - const controller = new AbortController() - const start = Date.now() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort('deadline') }, 100) - const result = await running.done - expect(Date.now() - start).toBeLessThan(5_000) - expect(result.signal).toBe('SIGTERM') - expect(result.exitCode).toBeNull() - }) - - it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) - await waitForStdout(running, 'ready\n') - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGKILL') - }) - - it('kills the whole process group (grandchildren die too)', async () => { - // The subshell writes the sleep's pid then waits on it; killing the - // group must take the sleep down with bash. - const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) - const grandchild = await waitForPidFile(pidFile) - expect(grandchild).toBeGreaterThan(0) - - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - await waitGone(grandchild) - }) - - it('aborts via AbortSignal mid-run', async () => { - const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort('user cancelled') }, 50) - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) - - it('throws when the signal is already aborted before spawn', () => { - const controller = new AbortController() - controller.abort('too late') - expect(() => runBash(spec('echo hi', { signal: controller.signal }))) - .toThrow(/aborted before spawn: too late/) - }) - - it('rejects with a spawn error for a nonexistent cwd', async () => { - await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) - .rejects.toThrow(/ENOENT/) - }) - - it('kill() is idempotent (second call does not restart escalation)', async () => { - const running = runBash(spec('sleep 60')) - running.kill() - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) - - it('bounds inherited-pipe draining after the shell exits', async () => { - const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) - const started = Date.now() - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) - const descendant = await waitForPidFile(pidFile) - try { - const result = await running.done - expect(Date.now() - started).toBeLessThan(1_000) - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('shell-done\n') - } finally { - process.kill(descendant, 'SIGKILL') - await waitGone(descendant) - } - }) -}) - -describe('stdin and extra env (set by in-process plugins)', () => { - it('writes stdin to the command and closes it', async () => { - const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('hello from stdin\n') - }) - - it('a command that reads stdin sees EOF when none is supplied', async () => { - // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no - // output (it does NOT block). - const result = await runBash(spec('cat')).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('') - }) - - it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { - // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). - // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. - const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done - expect(none.stdout.text).toBe('char\n') - const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done - expect(piped.stdout.text).toBe('socket\n') - }) - - it('merges ordinary extra env entries onto the scrubbed environment', async () => { - const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { - env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, - })).done - expect(result.stdout.text).toBe('alpha/beta\n') - }) - - it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { - // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. - // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit - // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', { - env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, - })).done - expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') - }) - - it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { - // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. - // The handler swallows that write error and `done` reports the child's real exit. - const big = 'x'.repeat(1024 * 1024) - const result = await runBash(spec('exit 7', { stdin: big })).done - expect(result.exitCode).toBe(7) - }) -}) - -describe('output truncation and spill', () => { - it('applies stdout and stderr caps independently', async () => { - const result = await runBash( - spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { - stdoutMaxBytes: 500, - stderrMaxBytes: 100, - }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(false) - expect(result.stdout.text).toBe('x'.repeat(500)) - expect(result.stderr.truncated).toBe(true) - expect(result.stderr.text.length).toBeLessThanOrEqual(100) - }) - - it('keeps the tail and spills the full stream to disk', async () => { - // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(true) - expect(result.stdout.text.length).toBeLessThanOrEqual(500) - expect(result.stdout.text).toContain('line-0200') - expect(result.stdout.text).not.toContain('line-0001') - expect(result.stdout.spillPath).toBeDefined() - const full = readFileSync(result.stdout.spillPath!, 'utf8') - expect(full).toContain('line-0001') - expect(full).toContain('line-0200') - }) - - it('does not truncate output exactly at the cap', async () => { - const result = await runBash( - spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(false) - expect(result.stdout.text.length).toBe(500) - expect(result.stdout.spillPath).toBeUndefined() - }) - - it('settles with the tail and no spill path when final spill close fails', async () => { - failNextClose.value = true - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(failNextClose.value).toBe(false) - expect(result.exitCode).toBe(0) - expect(result.stdout.truncated).toBe(true) - expect(result.stdout.text).toContain('line-0200') - expect(result.stdout.spillPath).toBeUndefined() - }) -}) - -describe('OutputCollector', () => { - it('keeps the tail of a single oversized chunk', () => { - const collector = new OutputCollector(10, 100, 'test', spillDir) - collector.push(Buffer.from('0123456789abcdef')) - const out = collector.finalize() - expect(out.text).toBe('6789abcdef') - expect(out.truncated).toBe(true) - expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef') - }) - - it('readFrom returns increments and flags lossy reads', () => { - const collector = new OutputCollector(10, 100, 'test', spillDir) - collector.push(Buffer.from('aaaaa')) - const first = collector.readFrom(0) - expect(first.text).toBe('aaaaa') - expect(first.lossy).toBe(false) - expect(first.nextOffset).toBe(5) - - collector.push(Buffer.from('bbbbb')) - const second = collector.readFrom(first.nextOffset) - expect(second.text).toBe('bbbbb') - expect(second.lossy).toBe(false) - - // Push enough to slide the window past the last offset. - collector.push(Buffer.from('c'.repeat(20))) - const third = collector.readFrom(second.nextOffset) - expect(third.lossy).toBe(true) - expect(third.text).toBe('c'.repeat(10)) - expect(third.spillPath).toBeDefined() - }) - - it('contains close failures and drops the spill path', () => { - const collector = new OutputCollector(4, 100, 'closefail', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - expect(collector.readFrom(0).spillPath).toBeDefined() - - failNextClose.value = true - let out: ReturnType - expect(() => { out = collector.finalize() }).not.toThrow() - - expect(failNextClose.value).toBe(false) - expect(out!.text).toBe('bbbb') - expect(out!.truncated).toBe(true) - expect(out!.spillPath).toBeUndefined() - }) - - it('discards a spill that exceeds its configured cap', () => { - const collector = new OutputCollector(4, 8, 'bounded', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - const spillPath = collector.readFrom(0).spillPath! - expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb') - - collector.push(Buffer.from('c')) - collector.push(Buffer.from('dddd')) - const out = collector.finalize() - expect(out.text).toBe('dddd') - expect(out.truncated).toBe(true) - expect(out.spillPath).toBeUndefined() - expect(() => readFileSync(spillPath)).toThrow() - }) - - it('does not create a spill when the first overflowing chunk exceeds the cap', () => { - const collector = new OutputCollector(4, 4, 'no-spill', spillDir) - collector.push(Buffer.from('abcdefgh')) - const out = collector.finalize() - expect(out.text).toBe('efgh') - expect(out.truncated).toBe(true) - expect(out.spillPath).toBeUndefined() - }) - - it('contains cleanup failures while disabling an oversize spill', () => { - const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - const spillPath = collector.readFrom(0).spillPath! - - failNextClose.value = true - failNextUnlink.value = true - expect(() => { collector.push(Buffer.from('c')) }).not.toThrow() - expect(failNextClose.value).toBe(false) - expect(failNextUnlink.value).toBe(false) - expect(collector.finalize().spillPath).toBeUndefined() - unlinkSync(spillPath) - }) -}) - -describe('killGroup', () => { - it('ignores non-positive pids', () => { - expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow() - expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow() - }) - - it('swallows ESRCH for vanished groups', async () => { - const running = runBash(spec('true')) - await running.done - expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() - }) -}) - -describe('abort edge cases', () => { - it('reports a fallback reason for reason-less pre-aborted signals', () => { - // Real AbortControllers always set a DOMException reason; signal-like - // objects from other libraries may not — the fallback covers them. - const bare = { - aborted: true, - reason: undefined, - addEventListener() {}, - removeEventListener() {}, - } as unknown as AbortSignal - expect(() => runBash(spec('echo hi', { signal: bare }))) - .toThrow(/aborted before spawn: aborted/) - }) - - it('reports the terminating signal of an externally self-killed command', async () => { - // runBash reports the raw signal; whether it counts as timeout/cancel is the - // executor's classification (a self-kill is neither) — see executor.spec.ts. - const result = await runBash(spec('kill -TERM $$')).done - expect(result.signal).toBe('SIGTERM') - }) -}) - -describe('environment and spill-file hardening', () => { - it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => { - process.env.DSH_TEST_API_KEY = 'super-secret' - process.env.DSH_TEST_TOKEN = 'also-secret' - process.env.DSH_TEST_PLAIN = 'visible' - try { - const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done - expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') - } finally { - delete process.env.DSH_TEST_API_KEY - delete process.env.DSH_TEST_TOKEN - delete process.env.DSH_TEST_PLAIN - } - }) - - it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { - process.env.DSH_STALE = 'old-value' - try { - const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { - dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, - })).done - expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') - } finally { - delete process.env.DSH_STALE - } - }) - - it('rejects DSH variables on the ordinary env channel', () => { - expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) - .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) - }) - - it('rejects ordinary variables on the managed env channel', () => { - const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => runBash(spec('true', { dshEnv: invalid }))) - .toThrow(/managed bash env.*PATH.*use env/) - }) - - it('creates spill files with owner-only permissions and random names', async () => { - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - const path = result.stdout.spillPath! - expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) - const mode = statSync(path).mode & 0o777 - expect(mode).toBe(0o600) - }) - - it('defaults spills into a private per-process directory', async () => { - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - ).done - const dir = dirname(result.stdout.spillPath!) - expect(dir).toMatch(/dsh-bash-/) - const mode = statSync(dir).mode & 0o777 - expect(mode).toBe(0o700) - }) - - it('killGroup never throws, even for EPERM-style failures', () => { - const spy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) - }) - try { - expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow() - } finally { - spy.mockRestore() - } - }) - - it('honors AbortSignal on background-style runs (no timeout)', async () => { - const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort() }, 50) - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) -}) diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index a55c76f00a..53ccc94926 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../bash/bash" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 9faf613c71..0f2240630c 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index b889692f3c..3945809fa9 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -34,7 +34,7 @@ export type Config = LocalConfig * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { - static inject = ['sandbox', 'sandboxPolicy'] + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] // No own Config: the sandbox default (mode + workspaceRoot) moved to // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config @@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Wrap one shell command via the `ctx.sandbox` provider: hand over the * exact `['bash', '-c', command]` argv this executor would spawn, get back * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns + * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 87bcffe9df..437078440c 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless integration of the real provider and executor through public run/start paths. With @@ -42,6 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 3ce944b07c..0c5cfbe563 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -9,6 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap @@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 6e8f2229a0..90a67999c8 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -58,9 +59,10 @@ async function setup( ...mode !== undefined ? { mode } : {}, ...workspaceRoot !== undefined ? { workspaceRoot } : {}, }) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor - bash.internals = { spillDir } return { ctx, bash, calls } } diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 6c212ee546..62b1569ee7 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless macOS integration of the real provider and executor through public run/start paths. @@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index f32efb60a0..520828acde 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/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 -README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370 -README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351 +README.md: d7bf746969f52000fe298b65b995b7c631d8001c +README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index b4ee66a1fa..d7bf746969 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). -`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). ## Model Experience diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 151d4bd7ab..14476b7703 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -33,7 +33,7 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 ## 模型体验 diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 93497df719..b8ff310f01 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index d9eedee052..4f8ae112a9 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -43,7 +43,10 @@ declare module 'cordis' { * failures settle as `killed` with the error on stderr. * - {@link BashProcess.readOutput} is incremental: consecutive reads never * repeat output. Lossy reads report truncation and available spill files. - * - Disposal kills all running background processes and awaits their exit. + * - A still-running background process is stopped and awaited when its + * owning composition tears down. With the subprocess seam that + * boundary is `ctx.subprocess` disposal, so a background process survives + * an executor-only reload. */ export abstract class BashExecutor extends Service { constructor(ctx: Context) { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index a504513417..6fdd8b36c7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -1,19 +1,17 @@ /** * Execution types for the bash executor seam. Background task semantics belong - * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. + * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The + * managed-environment and captured-output vocabulary is owned by the + * subprocess seam and re-exported here so bash consumers keep one import + * root. * @module dsh-bash/types */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' -/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ -export const DSH_ENV_PREFIX = 'DSH_' as const - -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` - -/** Trusted DeepSeek Harness variables for one bash execution. */ -export type DshEnvironment = Readonly> +export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -62,17 +60,18 @@ export interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -99,27 +98,17 @@ export interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined } -/** One captured stream: the (possibly truncated) text plus recovery info. */ -export interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} - /** The outcome of one completed (or killed) foreground run. */ export interface BashRunResult { /** Exit code; null when the process died from a signal. */ @@ -165,8 +154,9 @@ export interface BashProcessRead { /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the subprocess service's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ export interface BashProcess { /** Process lifecycle state (settled exactly once). */ diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 51175e6080..3f611c80e0 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index a89e147e0e..c34e1c6e7b 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index e1315c232a..434f53ca2a 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 80840fbf75..15222ee647 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' @@ -32,8 +33,9 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -46,8 +48,9 @@ async function setupWithTasks() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -275,8 +278,9 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -383,6 +387,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) @@ -400,6 +405,7 @@ describe('bash tool', () => { // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.tools.schemas()).toHaveLength(1) @@ -411,6 +417,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) ToolBash.apply(ctx, {}) const schema = ctx.tools.schemas()[0]! @@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index eaab0a43f9..9aa43145ed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -6,7 +6,7 @@ // approval/question requests exercise replay and composer takeover with stable rpcIds. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, @@ -167,6 +167,22 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } + // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the + // todo/write snapshot event feeding the TodoPanel plan strip. + const fixtureTodos = [ + { content: '梳理需求', status: 'completed' }, + { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '浏览器验收', status: 'pending' }, + ] + const todoArgs = JSON.stringify({ todos: fixtureTodos }) + toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + // The real tool appends the snapshot mid-execution — between tool/call and + // tool/result — so the fixture reproduces that exact ordering (the last + // toolTurn events run ... tool/call, tool/result, step/end, turn/end). + const callIndex = events.length - 4 + const callTime = events[callIndex]?.time as number + events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) + events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } @@ -281,6 +297,15 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */ +function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined { + for (let i = log.length - 1; i >= 0; i--) { + const event = log[i] + if (event !== undefined && event.type === 'todo/write') return event.data.todos + } + return undefined +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -619,12 +644,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50) + // Tail page carries the session-level todo projection (host parallel: full-log backscan). + const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined const doomed = failNextHistory failNextHistory = false const delay = historyDelayMs if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, page) + return ok(request, { ...page, ...todos === undefined ? {} : { todos } }) }, prompt: (request) => { const { sessionId: id, mode, content } = request.payload @@ -723,6 +750,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + delete: (request) => { + const { workspaceId } = request.payload + const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + if (index === -1) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + workspaces.splice(index, 1) + emitHost({ type: 'host/workspace-removed', workspaceId }) + return ok(request, { deleted: true as const }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -914,6 +955,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'command.list': return this.api.commands.list(request) // The in-memory execute never blocks, so a never-aborting signal is faithful here. diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..1c58e604ab 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient { rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), + delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0374f92b3b..ed5f88fb1d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -71,6 +71,21 @@ describe('createFixtureApi', () => { expect(empty.result.value).toEqual({ events: [], hasMore: false }) }) + it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => { + const api = createFixtureApi() + const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) + if (!tail.result.ok) throw new Error('history failed') + const events = tail.result.value.events.map(e => e.event) + const todoAt = events.findIndex(e => e.type === 'todo/write') + expect(todoAt).toBeGreaterThan(0) + // Production ordering (the tool appends mid-execution): call → snapshot → result. + expect(events[todoAt - 1]?.type).toBe('tool/call') + expect(events[todoAt + 1]?.type).toBe('tool/result') + const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) + expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) + expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) + }) + it('create adds a session and pushes host/session-added to open host streams', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -365,6 +380,31 @@ describe('createFixtureApi', () => { expect(noop.result.value.workspace.updatedAt).toBe(before) }) + it('workspace.delete removes only the Workspace row and emits the removal frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + await consuming + expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }]) + const list = await api.workspace.list(req({})) + if (!list.result.ok) throw new Error('workspace list failed') + expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) + const sessions = await api.sessions.list(req({})) + if (!sessions.result.ok) throw new Error('session list failed') + expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 5e73979b0b..d148c0dc5f 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -1,6 +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 -README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98 -README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5 +# pnpm run verify-translation-pairing --write packages/client/runtime/README.md +README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e +README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4724ebc75d..a434b2d571 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync. ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. + +`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a0076742e..a79fa99578 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。 ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 + +`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b1300a192c..b4a418628f 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -30,7 +30,7 @@ export type { export type { AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, - SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, + SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 49ae8634ec..e1b2c42962 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,11 +4,14 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +export type { TodoItem } + /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ export type AssistantBlock = @@ -241,4 +244,7 @@ export interface ConversationSnapshot { */ blank: boolean lastAgentError: string | null + /** Current whole-list `todo/write` projection — the tail page's full-log value, then each live + * write (last write wins); empty = the log holds no plan. */ + todos: readonly TodoItem[] } diff --git a/packages/client/runtime/src/client/sessions/service.ts.orig b/packages/client/runtime/src/client/sessions/service.ts.orig deleted file mode 100644 index deb1616a8a..0000000000 --- a/packages/client/runtime/src/client/sessions/service.ts.orig +++ /dev/null @@ -1,590 +0,0 @@ -/** - * SessionsService: root sessions service — list snapshot store (manager - * projection; carries `current`, the persisted selection every - * session-scoped surface keys off — migrated here from ui-layout per the - * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin - * Fiber + ctx.extend scope tag; one scope per session, agent id === session - * id), stable SessionBinding cache, ancestry walk. - * - * Scope lifecycle is stage-driven: a scope is minted lazily on first - * resolution (pure — resolution has no side effects and is render-safe); - * the event window and deferred teardown key off the STAGED session, which - * follows `list.current` exactly. Staging is the open signal: the window - * opens ⟺ the session is on stage (today the stage is `current`; the staged - * state can widen to a multi-pane list later). A session leaving the list - * tears its scope down immediately unless it is the staged one, whose scope - * survives frozen (read-only view) until the stage moves on. - */ -import type { Context, Fiber } from 'cordis' -import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' -import type { - HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, -} from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore } from '../contract/store.ts' -import { createSnapshotStore } from '../contract/store.ts' -import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' -import { SessionManager } from './manager.ts' -import type { SessionListPhase } from './manager.ts' -import type { Session } from './session.ts' - -/** Session list row projected from the host list RPC plus live stream increments. */ -export interface SessionSummary { - id: SessionId - /** Latest durable log-backed title, absent until the host projects one. */ - title?: string - /** Human-facing label: durable title, project basename, then session id. */ - displayTitle: string - cwd?: string - parentId?: SessionId - running: boolean - /** - * Empty-log bit (host summary derivation mirror). List surfaces hide blank - * sessions; New Session reuses a blank one targeting the same workspace. - * Filtering stays with the consumer — the store carries every row. - */ - blank: boolean - updatedAt: number -} - -/** - * Session list store shape. `current` rides the same snapshot (arbitrated: - * the single useSessions standard hook reads list and selection together — - * sidebar highlighting and SessionProvider share one fact source). - */ -export interface SessionListState { - ids: SessionId[] - byId: Record - current: SessionId | undefined - /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ - phase: SessionListPhase -} - -/** Structured session-create failure. */ -export class SessionCreateError extends Error { - override readonly name = 'SessionCreateError' - - /** - * @param rpcError - Host business or folded transport error. - * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. - */ - constructor( - readonly rpcError: RpcError, - readonly requestedSessionId: SessionId | undefined, - ) { - super(`session create failed: ${rpcError.code}: ${rpcError.message}`) - } -} - -/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ -export interface SessionBinding { - readonly sessionId: SessionId - readonly session: Session - readonly ctx: Context -} - -// Scope primitives live in ../agents/scope.ts (the client mirror of host -// dsh-scope, keyed by Agent identity); re-exported here so existing -// consumers keep their import site. -export { scopeOf } from '../agents/scope.ts' - -/** - * Workspace display title of a session cwd: the path's last non-empty - * segment (both separators accepted; trailing separators ignored), or '' - * for separator-only paths — callers own their fallback (session id, raw - * cwd, default-directory copy). The repo-wide single basename derivation — - * every surface naming a workspace (picker rows, toggle labels, list titles) - * calls this instead of re-splitting paths. - * @param cwd - workspace directory path. - * @returns basename title, or '' when no non-empty segment exists. - */ -export function workspaceTitleOf(cwd: string): string { - return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' -} - -/** - * Display title projection: durable title, project directory basename, then - * the raw id. - */ -function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { - if (title !== undefined) return title - if (cwd !== undefined && cwd !== '') { - const base = workspaceTitleOf(cwd) - if (base !== '') return base - } - return id -} - -interface ScopeRecord { - fiber: Fiber - ctx: Context - binding: SessionBinding - /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ - provideInfo: SessionProvideInfo -} - -/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */ -export interface SessionProvideContribution { - /** Bare observable sources, keyed by hook base name ('input' → useInput). */ - hooks?: Record> - /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */ - props?: Record -} - -/** - * Static declaration plus per-session resolver for one standard-kit - * contribution. The declared names let the renderer construct the same hook - * and prop surface while no session is current. - */ -export interface SessionProvideDescriptor { - /** Hook base names (`input` becomes `useInput`). */ - hooks?: readonly string[] - /** Plain standard-prop names. */ - props?: readonly string[] - /** Resolve every declared member for one definite session. */ - resolve(binding: SessionBinding): SessionProvideContribution -} - -/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ -export class SessionsService { - /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ - readonly list: SnapshotStore - /** The object-layer instance cluster and frame dispatch entry. */ - private readonly manager: SessionManager - - /** - * Persisted selection cell (the durable half of `list.current`). Private on - * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open} / {@link SessionsService.clear}. Projection - * validates it against the live list instead of destructively pruning, so a - * selection survives transient list states (reconnect re-pull) and - * resurfaces when its session returns. - */ - private readonly selection: SnapshotStore<{ sessionId?: SessionId }> - - private readonly scopes = new Map() - /** Registered per-session standard-props providers, in registration order. */ - private readonly providers: SessionProvideDescriptor[] = [] - /** Static no-session projection, rebuilt only when the provider roster changes. */ - private maybeInfo: SessionMaybeProvideInfo - /** - * The staged session id — follows `list.current` exactly, holding its last - * defined value across masked gaps (a transiently absent selection blanks - * `current` without moving the stage, so reconnect re-pulls and removals - * keep the staged scope's frozen view alive until the stage moves on). - */ - private watched: SessionId | undefined - /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ - private readonly deferredRemovals = new Set() - - /** - * @param ctx - client root context (scope fibers mount under it). - * @param api - wire client shared with every Session. - */ - constructor(private readonly rootCtx: Context, api: IApiClient) { - this.selection = createSnapshotStore<{ sessionId?: SessionId }>( - {}, - { persist: { name: 'dsh.sessions.current' } }) - this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) - this.list = createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'pending', - }) - // The manager owns wire truth; the store is its projection. Manager - // notifications are already microtask-batched. - this.manager.subscribe(() => { this.projectList() }) - // Stage follower: every current write (open() and projection alike) - // re-evaluates staging, so startup restore (persisted selection validated - // by the projection) and reconnect resurfacing open their window with no - // dedicated code path. Safe to run synchronously inside the store notify: - // the follower writes no list state — session.open()'s synchronous prefix - // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) - // The runtime's own contribution comes first: useSession rides the same - // provide channel every plugin uses (no renderer special case). - this.providers.push({ - hooks: ['session'], - resolve: binding => ({ hooks: { session: binding.session } }), - }) - this.maybeInfo = this.materializeMaybeProvideInfo() - rootCtx.reflect.provide('sessions', this, undefined) - } - - /** - * Register a per-session standard-props provider: every session-scope slot - * component receives the contributed members as standard props (`hooks` - * sources become `use` selector hooks on the render side; `props` - * spread verbatim). Contributions materialize lazily with the session's - * scope record and die with it. Registration order is resolution order; - * duplicate member names fail loud at materialization. - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). - */ - provide(descriptor: SessionProvideDescriptor): () => void { - this.providers.push(descriptor) - // Scopes may already exist (boot order: the list lands and resolves - // scopes before later plugins register) — their bundles must include - // every provider by first render, so re-materialize on roster change. - this.rematerializeProvideBundles() - return () => { - const at = this.providers.indexOf(descriptor) - if (at >= 0) this.providers.splice(at, 1) - this.rematerializeProvideBundles() - } - } - - /** Rebuild every live scope's standard-props bundle after a provider roster change. */ - private rematerializeProvideBundles(): void { - this.maybeInfo = this.materializeMaybeProvideInfo() - for (const record of this.scopes.values()) { - record.provideInfo = this.materializeProvideInfo(record.binding) - } - } - - /** Build the static no-session kit and reject duplicate declared names. */ - private materializeMaybeProvideInfo(): SessionMaybeProvideInfo { - const hooks: Record = {} - const props: Record = {} - for (const descriptor of this.providers) { - for (const name of descriptor.hooks ?? []) { - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = undefined - } - for (const name of descriptor.props ?? []) { - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = undefined - } - } - return { sessionId: undefined, hooks, props } - } - - /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */ - private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo { - const hooks: Record> = {} - const props: Record = {} - for (const descriptor of this.providers) { - const contribution = descriptor.resolve(binding) - const contributedHooks = contribution.hooks ?? {} - const contributedProps = contribution.props ?? {} - for (const name of Object.keys(contributedHooks)) { - if (!(descriptor.hooks ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared hook "${name}"`) - } - } - for (const name of Object.keys(contributedProps)) { - if (!(descriptor.props ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared prop "${name}"`) - } - } - for (const name of descriptor.hooks ?? []) { - const source = contributedHooks[name] - if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = source - } - for (const name of descriptor.props ?? []) { - if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = contributedProps[name] - } - } - return { sessionId: binding.sessionId, hooks, props } - } - - /** - * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere. - * @param id - session id (must exist in the list store). - */ - open(id: SessionId): void { - this.manager.select(id) - } - - /** - * Clear the current selection so the layout shows the no-session empty - * state (new-session affordance and the workspace preselection flow). - * Wipes the persisted selection too — a reload stays on empty until the - * user opens or starts a session. The staged scope keeps its frozen view - * per the masked-gap contract until the next open() moves the stage. - */ - clear(): void { - this.manager.clearSelection() - } - - /** - * Refresh the real Session baseline, reusing an in-flight pull. - * @returns completion of the current or newly started baseline pull. - */ - refresh(): Promise { - return this.manager.refreshList() - } - - /** - * Route a mux stream envelope into the Session object layer. - * @param envelope - validated mux stream envelope. - */ - handleMuxEnvelope(envelope: Parameters[0]): void { - this.manager.handleMuxEnvelope(envelope) - } - - /** - * Route a Host stream envelope into the Session object layer. - * @param envelope - validated Host stream envelope. - */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) - } - - /** Rebuild the Session baseline and every opened window after connection. */ - handleConnected(): void { - this.manager.handleConnected() - } - - /** - * Create a session on the host. Resolution guarantee: by the time the - * promise resolves, the created session is in the list store and - * {@link SessionsService.binding} resolves it — callers (New Session - * draft hand-off) may address the scope synchronously, without waiting a - * notifier flush. The synchronous projection below makes this structural - * rather than an accident of microtask ordering. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. - * @throws {SessionCreateError} with the requested id. - */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { - const result = await this.manager.create(opts) - if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) - this.projectList() - return result.value.sessionId - } - - /** - * Resolve an Agent-scoped context view (use-and-discard). - * @param id - session id (the agent identity — 1:1 same axis). - * @returns scoped ctx, or undefined for a session neither listed nor already scoped. - */ - scope(id: SessionId): Context | undefined { - return this.resolve(id)?.ctx - } - - /** - * Read the Agent scope tag off a context. Service-method seam: fetch - * bundles must reach scope resolution through ctx.sessions — a cross-bundle - * value import of the standalone helper would inline a second module - * instance whose private tag Symbol never matches. - * @param ctx - any client context. - * @returns the session id, or undefined on root contexts. - */ - scopeOf(ctx: Context): SessionId | undefined { - return scopeTagOf(ctx) - } - - /** - * Resolve the business Session behind an Agent-scoped context — the one - * hop every scoped consumer (event listeners, per-session controllers) - * takes from ctx-space into object-space (the client mirror of host - * `agent.session`). Same service-method seam as - * {@link SessionsService.scopeOf}. - * @param ctx - an Agent-scoped context. - * @returns the Session, or undefined when the ctx is untagged or its scope was pruned. - */ - sessionOf(ctx: Context): Session | undefined { - const id = scopeTagOf(ctx) - if (id === undefined) return undefined - return this.scopes.get(id)?.binding.session - } - - /** - * Resolve the stable session binding (scope-addressed assembly feed). Pure - * resolution — no staging, no window side effects. - * @param id - session id. - * @returns binding, or undefined for a session neither listed nor already scoped. - */ - binding(id: SessionId): SessionBinding | undefined { - return this.resolve(id)?.binding - } - - /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. - */ - provideInfo(id: string): SessionProvideInfo | undefined { - return this.resolve(id as SessionId)?.provideInfo - } - - /** - * Resolve the current-session-optional standard kit. Unknown or absent ids - * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. - */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo - } - - /** - * Move the stage to the list's current session: sweep teardowns deferred - * behind the previous occupant and pull the new occupant's history window. - * Staging IS the open signal — the window opens ⟺ the session is on stage - * — and open() is idempotent (an in-flight or completed open no-ops; a - * failed one retries the next time current is touched). - */ - private followCurrent(): void { - const snapshot = this.list.getSnapshot() - const current = snapshot.current - // A masked gap (current blanked while the selection's session is - // transiently absent) holds the stage: tearing down on the gap would - // destroy exactly the frozen scope the mask exists to preserve. - if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return - this.watched = current - this.sweepDeferred() - const record = this.resolve(current) - /* v8 ignore next 3 -- defensive: current is always a listed id (open() - * validates and the projection masks absent selections), so resolve - * cannot miss; kept so a future current writer cannot crash the notify. */ - if (record !== undefined) { - void record.binding.session.open() - } - } - - /** - * Breadcrumb feed: walk parentId links inside the list store. - * @param id - session id. - * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk). - */ - ancestry(id: SessionId): SessionSummary[] { - const { byId } = this.list.getSnapshot() - const chain: SessionSummary[] = [] - let cursor: SessionId | undefined = id - while (cursor !== undefined) { - const summary: SessionSummary | undefined = byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) - cursor = summary.parentId - } - return chain - } - - /** - * Lazily mint the scope + binding for an eligible session. Eligibility and - * prune share one predicate (decision 12): listed on the host — a scope is - * born when its session enters the client's view (list mirror row from the - * baseline pull, a create() echo, or the session-added frame) and dies with - * the prune when the row leaves. - */ - private resolve(id: SessionId): ScopeRecord | undefined { - const existing = this.scopes.get(id) - if (existing !== undefined) return existing - if (!this.eligible(id)) return undefined - const { fiber, ctx } = createScope(this.rootCtx, id) - const session = this.manager.get(id) - // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); - // mint and bind are one step so a live scope record implies a bound actx. - session.bindScope(ctx) - const binding: SessionBinding = { sessionId: id, session, ctx } - const record: ScopeRecord = { - fiber, - ctx, - binding, - // Sources are bare observables; React binds selector hooks at its own seam. - provideInfo: this.materializeProvideInfo(binding), - } - this.scopes.set(id, record) - return record - } - - /** The one aliveness predicate shared by scope mint and prune: host-listed. */ - private eligible(id: SessionId): boolean { - return this.list.getSnapshot().byId[id] !== undefined - } - - /** Project the manager's list snapshot into the store (title derivation is display-only). */ - private projectList(): void { - const { items, current, phase } = this.manager.getListSnapshot() - const ids: SessionId[] = [] - const byId: Record = {} - for (const entry of items) { - ids.push(entry.sessionId) - byId[entry.sessionId] = { - id: entry.sessionId, - displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), - running: entry.running, - blank: entry.blank, - updatedAt: entry.updatedAt, - ...(entry.title !== undefined ? { title: entry.title } : {}), - ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), - ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), - } - } - const persisted = this.selection.getSnapshot().sessionId - // No current (cleared, or masked gap) wipes the persisted cell — a reload - // stays on empty; the in-memory selection still resurfaces a masked id. - if (current === undefined) { - if (persisted !== undefined) this.selection.set({}) - } else if (byId[current] !== undefined && persisted !== current) { - this.selection.set({ sessionId: current }) - } - this.list.set({ ids, byId, current, phase }) - this.pruneScopes(byId) - } - - /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ - private pruneScopes(byId: Record): void { - void byId - for (const [id, record] of this.scopes) { - if (this.eligible(id)) continue - if (id === this.watched) { - this.deferredRemovals.add(id) - continue - } - this.scopes.delete(id) - this.deferredRemovals.delete(id) - this.dropScope(id, record) - } - } - - /** - * One teardown for the whole per-session axis (decision 12): the scope - * fiber (cascading every actx-registered effect: input shell, slash - * controller, popup, plugin stores, listeners), the session-keyed slot - * stores, and the Session instance itself — the host session log is the - * durable truth, a reopen lazily rebuilds and backfills via open(). - */ - private dropScope(id: SessionId, record: ScopeRecord): void { - void record.fiber.dispose() - // Release the Session's dispatch point with the scope it belongs to (a - // surviving instance — the live Intent — rebinds when resolve re-mints). - record.binding.session.unbindScope() - // Optional lookup: slots and sessions are sibling services with no - // declared dependency; a slots-less boot (object-layer tests) skips. - this.rootCtx.get('slots')?.pruneStoreScope(id) - this.manager.drop(id) - } - - /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ - private sweepDeferred(): void { - for (const id of [...this.deferredRemovals]) { - /* v8 ignore next -- defensive: only the staged id ever defers, and every - * stage move sweeps first, so the set cannot contain the id the stage just - * moved to; kept as a guard against future extra sweep call sites. */ - if (id === this.watched) continue - // Eligible again? (A re-added id cancels the deferred teardown.) - if (this.eligible(id)) { - this.deferredRemovals.delete(id) - continue - } - const record = this.scopes.get(id) - this.deferredRemovals.delete(id) - /* v8 ignore next -- defensive: prune deletes a scope and its deferral - * together, so a deferred id always still owns its record; kept so a - * future teardown path cannot double-dispose. */ - if (record !== undefined) { - this.scopes.delete(id) - this.dropScope(id, record) - } - } - } -} diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 75c55bc4bd..1dd0283429 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView, @@ -99,6 +99,9 @@ export class Session implements ObservableSnapshot { private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + /** Current whole-list todo/write projection: each tail history response replaces it (an omitted + * field is the authoritative empty list) and every live write overwrites it. */ + private todos: readonly TodoItem[] = [] /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -479,13 +482,13 @@ export class Session implements ObservableSnapshot { this.openError = result.error return } - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore) + if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } this.openState = 'open' } catch (error) { @@ -503,11 +506,19 @@ export class Session implements ObservableSnapshot { * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here * (doOpen flips it after install), so recursing would push every buffered event straight * back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */ - private installWindow(entries: HistoryEntry[], hasMore: boolean): void { + private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore + // Session-level projection from the tail page (full-log latest todo/write, + // independent of the window); an in-window write below re-derives the same + // value, and later live events keep overwriting it. Every caller here is a + // tail request (no beforeSeq), which the host answers with the projection + // or omits it only when the full log holds no todo/write — so an absent + // field is the authoritative empty list, not a missing carrier. Assigning + // it clears a plan the log never kept (a write lost to a host crash). + this.todos = todos ?? [] this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() const buffered = this.liveBuffer @@ -558,7 +569,7 @@ export class Session implements ObservableSnapshot { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES }) // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } } catch (error) { console.error('[web-runtime] gap repair failed:', error) @@ -678,6 +689,10 @@ export class Session implements ObservableSnapshot { if (this.openCalls.delete(String(event.data.callId))) this.callsRev++ return } + case 'todo/write': { + this.todos = event.data.todos + return + } case 'turn/end': { // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. @@ -722,7 +737,10 @@ export class Session implements ObservableSnapshot { /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). + * todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log + * projection, not derivable from an arbitrary window). The window always extends to the log + * tail, so an in-window todo/write can only overwrite it with the same latest value. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() @@ -792,6 +810,7 @@ export class Session implements ObservableSnapshot { promptError: this.promptError, blank: this.blankBit, lastAgentError: this.lastAgentError, + todos: this.todos, } } } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index e7caecfe82..ce4198cd01 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot { error: RpcError | null } +type WorkspaceDelta = + | { type: 'upsert'; workspace: WorkspaceView } + | { type: 'remove'; workspaceId: WorkspaceId } + /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { private items: Workspace[] = [] @@ -28,7 +32,16 @@ export class WorkspaceManager { private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null private inflight: Promise | null = null - private refreshFrames: WorkspaceView[] | null = null + private refreshFrames: WorkspaceDelta[] | null = null + /** + * Ids this process has seen removed, kept for the connection's lifetime so + * a late changed frame or a stale baseline row cannot resurrect a deleted + * row. Correctness rests on Host ids never being reused (the registry mints + * a fresh `randomUUID` per record, including when the same directory is + * registered again) — a path-derived id scheme would turn these entries + * into permanent blindfolds and must clear them instead. + */ + private readonly removedIds = new Set() private snapshotCache: WorkspaceListSnapshot private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() @@ -51,7 +64,7 @@ export class WorkspaceManager { this.state = 'loading' this.error = null const established = this.itemViews() - const frames: WorkspaceView[] = [] + const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() this.inflight = (async () => { @@ -61,7 +74,8 @@ export class WorkspaceManager { let items = this.phase === 'pending' ? result.value.items : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) - for (const workspace of frames) items = upsertWorkspace(items, workspace) + items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) + for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) this.state = 'idle' this.phase = 'ready' @@ -111,6 +125,18 @@ export class WorkspaceManager { return result } + /** + * Delete a Workspace registration and remove its local projection from the + * unary response without waiting for the Host frame. + * @param workspaceId - target workspace. + * @returns the wire result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const { result } = await this.api.workspace.delete({ workspaceId }) + if (result.ok) this.remove(workspaceId, true) + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -139,6 +165,7 @@ export class WorkspaceManager { */ handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) + else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) } /** Re-pull the baseline after each connection generation. */ @@ -175,7 +202,8 @@ export class WorkspaceManager { /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { - this.refreshFrames?.push(view) + if (this.removedIds.has(view.workspaceId)) return + this.refreshFrames?.push({ type: 'upsert', workspace: view }) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) // Mutation responses and changed frames race (two carriers, no ordering): // reject a snapshot strictly older than the installed projection so a @@ -195,6 +223,24 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Remove one id idempotently and retain a tombstone against late echoes. */ + private remove(workspaceId: WorkspaceId, direct = false): void { + this.refreshFrames?.push({ type: 'remove', workspaceId }) + this.removedIds.add(workspaceId) + const items = this.items.filter(item => + item.getSnapshot().view?.workspaceId !== workspaceId) + if (items.length === this.items.length) { + // The Host frame may have removed the row first but left its batched + // notification pending. A successful unary echo still flushes that + // committed state before the user action resolves. + if (direct) this.notifier.notifyNow() + return + } + this.items = items + if (direct) this.notifier.notifyNow() + else this.notifier.markDirty() + } + private installViews(views: readonly WorkspaceView[]): void { const existing = new Map( this.items.flatMap((workspace) => { @@ -234,3 +280,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi ? [workspace, ...items] : items.map((item, position) => position === index ? workspace : item) } + +/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ +function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { + return delta.type === 'upsert' + ? upsertWorkspace(items, delta.workspace) + : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) +} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1e281ca792..4cb26aa220 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -174,6 +174,16 @@ export class WorkspacesService { return result.value.workspace } + /** + * Delete one Workspace registration. Sessions, session logs, and the + * directory remain Host-owned outside this operation. + * @param workspaceId - target workspace. + */ + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.manager.delete(workspaceId) + if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 7fb150bb20..ada8550136 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -40,6 +40,8 @@ export const ev = { at(seq, { type: 'step/end', data: { turn, step } }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), + todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent => + at(seq, { type: 'todo/write', data: { todos } }), } /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..d906ff92a8 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -62,7 +62,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) @@ -96,6 +96,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) @@ -103,6 +106,7 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index f5cf9a2138..6c223ef58b 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: return { api, session: new Session(SID, api) } } -function histResponse(events: SessionEvent[], hasMore = false) { +function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) { // history now returns HistoryEntry[] ({event, view?}); these tests are view-less. - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) + return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } })) } describe('open', () => { @@ -153,6 +153,42 @@ describe('live event path', () => { }) }) + it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => { + const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }] + const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }] + const { session } = await opened() + expect(session.getSnapshot().todos).toEqual([]) + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.todoWrite(6, listA)) + expect(session.getSnapshot().todos).toEqual(listA) + feed(ev.todoWrite(7, listB)) + expect(session.getSnapshot().todos).toEqual(listB) + // Window replay converges on the same last snapshot (history contains both writes). + const replayed = makeSession() + replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)]) + await replayed.session.open() + expect(replayed.session.getSnapshot().todos).toEqual(listB) + }) + + it('seeds todos from the tail page projection when the last write precedes the window', async () => { + const list = [{ content: '窗口外的计划', status: 'in_progress' as const }] + // Cold open: the page window carries NO todo/write; the projection rides the response. + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list) + await session.open() + expect(session.getSnapshot().todos).toEqual(list) + // Paging an older window in must not clear the session-level projection. + api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false) + await session.loadOlder() + expect(session.getSnapshot().todos).toEqual(list) + // A later live write still overrides the seeded projection. + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }]) + }) + it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] @@ -166,6 +202,37 @@ describe('live event path', () => { const seqs = session.getSnapshot().nodes.map(n => n.seq) expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 }) + + it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => { + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 + expect(session.getSnapshot().todos).toEqual([]) + // The missed range contained a todo/write that the repulled page no longer + // covers; the response's session-level projection is the only carrier. + const current = [{ content: '断线期间写的', status: 'in_progress' as const }] + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current) + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') }) + await vi.waitFor(() => { + expect(api.callsOf('session.history').length).toBe(2) + }) + await Promise.resolve() + expect(session.getSnapshot().todos).toEqual(current) + }) + + it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => { + // Live write lands, then the host crashes before persisting it: the + // authoritative log holds no todo/write, so the resync tail response + // carries no projection — an omitted field on a tail request is the empty + // list, not a missing carrier, and the rolled-back plan must disappear. + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }]) + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.resync() + expect(session.getSnapshot().todos).toEqual([]) + }) }) describe('paging', () => { diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index d020b74fec..210c04d896 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -76,6 +76,48 @@ describe('WorkspaceManager', () => { ok: false, error: { code: 'internal', message: 'create transport' }, }) }) + + it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const hydration = manager.refresh() + manager.handleHostEnvelope({ + rpcId: 'removed' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] })) + await hydration + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + + manager.handleHostEnvelope({ + rpcId: 'late-change' as never, + payload: { type: 'host/workspace-changed', workspace: workspace('gone') }, + }) + manager.handleHostEnvelope({ + rpcId: 'duplicate-remove' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + }) + + it('removes from the unary delete echo while a refresh is in flight', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] })) + const manager = new WorkspaceManager(api) + await manager.refresh() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const refresh = manager.refresh() + + await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }]) + expect(manager.getSnapshot().items).toEqual([]) + gate.resolve(ok({ items: [workspace('gone')] as never[] })) + await refresh + expect(manager.getSnapshot().items).toEqual([]) + }) }) describe('WorkspacesService', () => { @@ -175,4 +217,20 @@ describe('WorkspacesService', () => { })) await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) }) + + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + await workspaces.refresh() + await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined() + expect(workspaces.list.getSnapshot().items).toEqual([]) + + api.onWorkspaceDelete = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, + })) + await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) + }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8117d42ea4..56923eff77 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -1,6 +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 -README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467 -README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311 +# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md +README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6 +README.zh.md: 88992176165ab11050a30c7df381479796908ba2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b9ec555f15..453922dafd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. @@ -12,6 +12,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. + Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). @@ -31,3 +33,4 @@ None; this package neither assembles nor sends a provider request. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b1e3c1f433..8899217616 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent;没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`,ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份,并保留任何仍需连接或交付的提示词;ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。 @@ -12,6 +12,8 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 + 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 @@ -31,3 +33,4 @@ - **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 +- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c8d5be336d..7a229758b1 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -13,6 +13,8 @@ import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { todoToolview } from './toolviews/todo-row.tsx' +import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession } from './skeleton/ConversationSession.tsx' @@ -179,9 +181,16 @@ export function apply(ctx: Context): void { // 'conversation.chat.toolview' declaration) is on the ledger. ctx.plugin(ConversationService, { input: inputHub }) - // The bash sample rides that exact seam, in third-party posture. + // The bash sample rides that exact seam, in third-party posture + // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) + // The todo_write row rides the same seam (a product registration, not a sample). + ctx.plugin(todoToolview) + + // The plan strip rides the input dock above the queue rows (same posture). + ctx.plugin(todoDockEntry) + // The read-only queue dock entry (T9 file territory) rides the same // registration seam into the input dock declared above. ctx.plugin(queueDockEntry) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d548f2d7be..6d75f9a519 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -37,17 +37,11 @@ border-radius: 6px; } -/* Selection linkage: the selected call row wears the blue outline. - button-info-fill flips 500→400 with the theme, hitting the darker-blue - dark-mode spec exactly (business-primary stays 500 on both). */ -.callRow[data-selected] { - outline: 1.5px solid var(--dsw-alias-button-info-fill); - outline-offset: 1px; -} +/* Selection still sets data-selected for details linkage; no outline — + tool rows match Think chrome (no selected ring). */ /* run_code sub-dispatch rows: indented under the parent row, left-edged so - the code turn reads as one unit; each nested row is itself a .callRow - (same components, same selection outline as top-level rows). */ + the code turn reads as one unit; each nested row is itself a .callRow. */ .subCalls { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 047878f1d0..22537f9cfe 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -1,10 +1,11 @@ -/* User bubble: right-aligned, figma r22 fill = the bubble specific token - (#EDF3FE light / dark pair rides the token sheet). */ +/* User bubble: right-aligned column (bubble + IconActions). Figma + User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */ -/* Block spacing is the flow column's gap alone — no extra padding here. */ .userRow { display: flex; - justify-content: flex-end; + flex-direction: column; + align-items: flex-end; + gap: 6px; } .bubble { @@ -19,6 +20,46 @@ color: var(--dsw-alias-label-primary); } +.actions { + display: flex; + align-items: center; + gap: 10px; + height: 28px; +} + +/* Hover-capable pointers: hide until the row is hovered/focused. Touch / + hover:none keeps actions visible (opacity:0 still hit-tests). */ +@media (hover: hover) { + .actions { + opacity: 0; + transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); + } + + .userRow:hover .actions, + .userRow:focus-within .actions { + opacity: 1; + } +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + border: none; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + .badge { display: inline-block; margin-bottom: 4px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index e79304fc19..4ecfdadf88 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,14 +1,18 @@ -// MessageItem: the four simple node kinds — user bubble (right-aligned), -// steering (badged bubble), context injection and unknown-surface JSON rows. -// Props are frozen node slices off the snapshot cache; memo holds across -// streaming because unchanged nodes keep their references. +// MessageItem: the four simple node kinds — user bubble (right-aligned, with +// copy / branch / edit IconActions), steering (badged bubble), context +// injection and unknown-surface JSON rows. Props are frozen node slices off +// the snapshot cache; memo holds across streaming because unchanged nodes +// keep their references. -import { memo } from 'react' +import { memo, useCallback } from 'react' import type { ReactNode } from 'react' import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconBranchOutline16, IconCopyOutline16, IconEditOutline16, + JsonBlock, MessageText, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' export interface MessageItemProps { @@ -26,6 +30,35 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } +/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ +async function writeClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + } catch { + // Denied permissions / iframe policy. + } + return + } + const exec = typeof document.execCommand === 'function' + ? document.execCommand.bind(document) + : undefined + if (exec === undefined) return + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + try { + exec('copy') + } catch { + // Clipboard unavailable; the button stays idle. + } + el.remove() +} + /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -58,15 +91,52 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */ +function UserActions({ text }: { text: string }) { + const onCopy = useCallback(() => { + void writeClipboard(text) + }, [text]) + return ( +
    + + + + + + + + + +
    + ) +} + export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { switch (node.kind) { - case 'user': + case 'user': { + const { text, rest } = contentText(node.content) + return ( +
    +
    + {projectUserText(text)} + {rest.map((block, i) => )} +
    + +
    + ) + } case 'steering': { const { text, rest } = contentText(node.content) return (
    - {node.kind === 'steering' && 插话} + 插话 {projectUserText(text)} {rest.map((block, i) => )}
    diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index fcd7e75732..f5b5047c7b 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) { } /** - * The dock entry as a plain registrant plugin (bash-sample posture). + * The dock entry as a plain registrant plugin (bash posture). * `inject: ['conversation']` is the ordering seam: the conversation service * mounts after ui-conversation's slot registrations, so the * 'conversation.input.dock' declaration is on the ledger by then. diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index b17027a153..7b2711da39 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -21,8 +21,9 @@ flex-direction: column; align-items: center; /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by - the chat scroller. Top 8 hosts the error strip's breathing room. */ - padding: 8px 32px 12px; + the chat scroller. Top 6 is the gap under the dock todo strip (12px todo + margin + 6px here); error/status strips still carry their own margin. */ + padding: 6px 32px 12px; } .hero { @@ -323,7 +324,7 @@ .stopping, .stopping:hover { background: var(--dsw-alias-button-primary-dimmed); - color: var(--dsw-alias-brand-text); + color: var(--dsw-alias-label-primary); } .retry { diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css new file mode 100644 index 0000000000..8086cbfea7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -0,0 +1,116 @@ +/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): + tip surface, 14px radius, status icons + secondary item labels. Column is + calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ + +.root { + flex: none; + overflow: hidden; + margin: 0 auto; + width: calc(100% - 88px); + max-width: 776px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 14px; + background: var(--dsw-specific-tip); +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 16px; +} + +.header { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 0; + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.progress { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + font-size: 13px; + line-height: 20px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + max-height: 180px; + overflow-y: auto; +} + +.item { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.glyph { + display: grid; + flex: none; + place-items: center; + width: 16px; + height: 16px; +} + +.glyphCompleted { + color: var(--dsw-alias-state-success-primary); +} + +.glyphProgress { + color: var(--dsw-alias-state-business-primary); + animation: todo-progress-spin 1s linear infinite; +} + +.glyphPending { + color: var(--dsw-alias-label-caption); +} + +@keyframes todo-progress-spin { + to { + transform: rotate(360deg); + } +} + +/* Figma strip is single-line; long items ellipsize with no inline expand. */ +.content { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx new file mode 100644 index 0000000000..16edc423c0 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -0,0 +1,140 @@ +// TodoPanel: persistent plan strip above the composer (the web counterpart +// of the TUI plan panel). Renders the latest todo/write whole-list snapshot — +// no data of its own, hidden while the list is empty. Mounted through the +// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does +// the selecting, so the panel takes the plain list and stays framework-free. +// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded). + +import { useId, useState } from 'react' +import type { Context } from 'cordis' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' +import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './TodoPanel.module.css' + +export interface TodoPanelProps { + /** The session's current plan (empty renders nothing) — selected by the dock adapter. */ + todos: readonly TodoItem[] +} + +/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable todo status: ${String(value)}`) +} + +/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */ +function CompletedGlyph() { + return ( + + ) +} + +/** In-progress: business-blue ring fading out; CSS spins the svg. */ +function ProgressGlyph() { + const gradientId = useId() + return ( + + ) +} + +/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */ +function PendingGlyph() { + return ( + + ) +} + +function StatusGlyph({ status }: { status: TodoItem['status'] }) { + switch (status) { + case 'completed': return + case 'in_progress': return + case 'pending': return + /* v8 ignore next -- closed TodoItem status union */ + default: return assertNever(status) + } +} + +/** Header summary: "/ tasks · in progress". */ +function progressLabel(todos: readonly TodoItem[]): string { + const done = todos.filter(t => t.status === 'completed').length + const active = todos.filter(t => t.status === 'in_progress').length + return `${done}/${todos.length} tasks · ${active} in progress` +} + +export function TodoPanel({ todos }: TodoPanelProps) { + const [collapsed, setCollapsed] = useState(false) + if (todos.length === 0) return null + + return ( +
    +
    + + {!collapsed && ( +
      + {todos.map(item => ( +
    • + + {item.content} +
    • + ))} +
    + )} +
    +
    + ) +} + +/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ +export type TodoDockProps = PropsRuntime<'conversation.input.dock'> + +/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */ +export function TodoDock({ useSession }: TodoDockProps) { + const todos = useSession(s => s.todos) + return +} + +/** + * The plan strip as a plain registrant plugin (QueueDock posture). + * `inject: ['conversation']` is the ordering seam: the conversation service + * mounts after ui-conversation's slot registrations, so the + * 'conversation.input.dock' declaration is on the ledger by then. + */ +export const todoDockEntry = { + name: 'conversation-todo-dock', + inject: ['slots', 'conversation'], + /** + * Register the plan strip into the input dock (list entry, above the queue rows). + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + }, +} diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 83c2329fc5..9c42e69b59 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,29 +1,32 @@ -/* Sample bash rows: deliberately distinct from ToolRow so the differential - registry hit is visible at a glance. */ +/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ -.row { +.root { display: flex; align-items: center; - gap: 8px; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - font-family: var(--ds-font-family-code); - font-size: 13px; } -.row:hover { +.root:hover { background: var(--dsw-alias-interactive-bg-hover); } -.prompt { +.leading { flex: none; - color: var(--dsw-alias-state-success-primary); + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); } .scopeBadge { flex: none; + margin-right: 8px; padding: 0 6px; border-radius: 6px; font-size: 11px; @@ -32,17 +35,38 @@ background: var(--dsw-alias-state-business-primary); } -.command { +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-primary-dimmed); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); } -.err { - flex: none; - color: var(--dsw-alias-state-error-primary); - font-size: 11px; +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 9968c3b46e..616eee5943 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -1,35 +1,54 @@ -// Bash toolview sample, written in third-party posture: everything below uses -// only the public slot surface (ctx.slots.register into the keyed -// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof -// that a plain plugin can take over a tool row with zero dedicated machinery. -// Session-dimension differentiation happens INSIDE the component (the -// canonical sub-agent scenario): rows in child sessions render the scoped -// variant, derived from the standard useSessions kit — no registry predicates. +// Bash toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Product chrome matches ToolRow / Think (figma: Bash · {description}). +// Child sessions keep a scoped badge so session-dimension differentiation stays +// observable inside the component (no parallel registry). import type { Context } from 'cordis' +import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { toolRowModel } from '../contract/tool-call-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './bash-sample.module.css' -/** Bash row: command-first monospace summary replacing the generic card. - * Sub-session rows (parentId present) swap the prompt for a scoped badge — - * the differential stays observable per session from one registration. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'running': return + case 'error': return + case 'stopped': return + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) - if (isChild) { - return ( -
    - scoped - {model.summary} -
    - ) - } + const status = stateStatus(model.state) return ( -
    - $ - {model.summary} - {model.state === 'error' && failed} +
    + {leadingFor(model.state)} + {status !== null && {status}} + {isChild && scoped} + {model.title} + + {model.summary}
    ) } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css new file mode 100644 index 0000000000..ff4068d49c --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -0,0 +1,42 @@ +/* todo_write plan-update row: title + progress summary on one line. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + height: 24px; + min-width: 0; + cursor: pointer; + border-radius: 6px; + font-size: 13px; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.badge { + flex: none; + color: var(--dsw-alias-state-business-primary); +} + +.title { + flex: none; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); +} + +.err { + flex: none; + color: var(--dsw-alias-state-error-primary); + font-size: 11px; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx new file mode 100644 index 0000000000..353e7a5441 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -0,0 +1,93 @@ +// todo_write toolview: plan-flavored summary row replacing the generic +// "Tool call" card, registered into the keyed 'conversation.chat.toolview' +// hole like the bash sample (a product registration, not a sample). The row +// summarizes the written list (counts + active item) from the call args; the +// durable list itself renders in the TodoPanel above the composer, so the +// row stays one line. + +import type { KeyboardEvent } from 'react' +import type { Context } from 'cordis' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import css from './todo-row.module.css' + +/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ +interface TodoWriteItem { content?: unknown; status?: unknown } + +function isItem(value: unknown): value is TodoWriteItem { + return typeof value === 'object' && value !== null +} + +function summarize(argsRaw: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(argsRaw) + } catch { + // Mid-stream truncation or malformed model JSON: fall back to the generic summary. + return null + } + // Valid JSON with an invalid shape (null root, non-array todos, null items — + // a rejected tool/call retains such args verbatim): same generic fallback. + if (typeof parsed !== 'object' || parsed === null) return null + const todos = (parsed as { todos?: unknown }).todos + if (!Array.isArray(todos) || !todos.every(isItem)) return null + const done = todos.filter(t => t.status === 'completed').length + const active = todos.find(t => t.status === 'in_progress') + const head = `${done}/${todos.length} 已完成` + return typeof active?.content === 'string' && active.content !== '' + ? `${head} · ${active.content}` + : head +} + +/** One-line plan update row (click opens the raw args in details). Non-ok + * execution states keep the generic row's dot semantics — a cancelled call + * wrote no todo/write, so it must not read as a completed update. */ +export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' + const summary = summarize(argsRaw) ?? model.summary + // Button semantics, not a +
    +
    - ) - } - // eslint-disable-next-line react/no-danger -- shiki's output is a static - // span tree it generated from `code` (no user HTML passes through), the - // sanctioned innerHTML consumption path per shiki's own docs. - return
    + {body} +
    + ) } diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 36b1dc2b55..a189528bc9 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -1,95 +1,168 @@ +/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS + Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are + intentionally absent (no matching DOM). Token names match that sheet. */ + .markdown { - display: flex; min-width: 0; - flex-direction: column; - gap: 12px; overflow-wrap: anywhere; font: var(--dsw-font-markdown-base); + color: var(--dsw-alias-label-primary); } -.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) { - margin: 0; +.markdown strong { + font-weight: 600; } .markdown h1 { font: var(--dsw-font-markdown-h1); + margin: 32px 0 16px; } .markdown h2 { font: var(--dsw-font-markdown-h2); + margin: 32px 0 16px; } .markdown h3 { font: var(--dsw-font-markdown-h3); + margin: 32px 0 16px; } -.markdown :where(h4, h5, h6) { +.markdown h4 { font: var(--dsw-font-markdown-h4); + margin: 16px 0; } -.markdown :where(strong, th) { - font-weight: var(--dsw-font-markdown-base-strong-font-weight); +.markdown :where(h5, h6) { + font: var(--dsw-font-markdown-base-strong); + margin: 16px 0; } -.markdown :where(ul, ol) { - padding-inline-start: 24px; +.markdown :where(h1, h2, h3, h4, h5, h6) strong { + font-weight: inherit; } -.markdown li + li { - margin-block-start: 4px; +.markdown p { + margin: 16px 0; } -.markdown li > :where(ul, ol) { - margin-block-start: 4px; +/* Tighten h4–h6 against a following list (design: 8px gap). */ +.markdown :where(h4, h5, h6) + :where(ul, ol) { + margin-top: 8px; } -.markdown blockquote { - padding-inline-start: 12px; - border-inline-start: 3px solid var(--dsw-alias-markdown-citation); - color: var(--dsw-alias-label-secondary); +.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) { + margin-bottom: 8px; } .markdown a { + /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet + keeps design-platform brand-text as near-black, so links use the blue + business-primary alias instead. */ color: var(--dsw-alias-state-business-primary); - text-decoration: underline; - text-underline-offset: 2px; + transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out); + position: relative; + text-decoration: none; + /* Transparent hit-area padding; literal zero-alpha only (no painted color). */ + border-left: 3px solid rgb(255 255 255 / 0); + border-right: 3px solid rgb(255 255 255 / 0); + border-top: 2px solid rgb(255 255 255 / 0); + border-bottom: 2px solid rgb(255 255 255 / 0); + margin-left: -3px; + margin-right: -3px; } -.markdown :not(pre) > code { - padding: 2px 4px; - border-radius: 4px; - background: var(--dsw-alias-markdown-inline-code); - font: var(--dsw-font-markdown-code); +.markdown a:hover, +.markdown a:focus { + outline: none; + text-decoration: underline var(--dsw-alias-state-business-primary); } -.markdown pre { - max-width: 100%; - overflow-x: auto; - overscroll-behavior-x: contain; - padding: 12px 16px; - border-radius: 8px; - background: var(--dsw-alias-markdown-code-block); - font: var(--dsw-font-markdown-code-block); +.markdown a:focus-visible { + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); } -.markdown pre code { - padding: 0; - background: transparent; - font: inherit; - overflow-wrap: normal; - word-break: normal; - white-space: pre; +.markdown :where(ul, ol) { + margin: 16px 0; + padding-left: 18px; +} + +.markdown li:not(:first-child) { + margin-top: 6px; +} + +.markdown li > :where(ul, ol) { + margin-top: 4px; +} + +.markdown li::marker { + line-height: 28px; + color: var(--dsw-alias-label-secondary); +} + +/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */ +.markdown :where(ul, ol) ol { + list-style-position: inside; + padding-left: 0; +} + +.markdown :where(ul, ol) ol li p { + display: inline; +} + +.markdown li > p { + margin: 8px 0; +} + +.markdown li > *:first-child { + margin-top: 0; +} + +/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */ +.markdown li > *:last-child:not(:global(.md-code-block)) { + margin-bottom: 0; } .markdown hr { - width: 100%; - border: 0; - border-block-start: 1px solid var(--dsw-alias-markdown-citation); + display: block; + border: none; + height: 1px; + margin: 32px 0; + background: var(--dsw-alias-border-l2); +} + +.markdown blockquote { + border-left: 2px solid var(--dsw-alias-label-caption); + margin: 16px 0 0; + padding-left: 14px; +} + +.markdown pre { + margin: 16px 0; + font-family: var(--ds-font-family-code); + overflow: auto; +} + +.markdown :not(pre) > code { + display: inline-flex; + align-items: center; + box-sizing: border-box; + font: var(--dsw-font-markdown-code); + font-family: var(--ds-font-family-code); + font-size: 0.875em !important; + background-color: var(--dsw-alias-markdown-inline-code); + border-radius: 6px; + padding: 0 5px; +} + +.markdown :where(h1, h2, h3, h4, h5, h6) code { + font: inherit; + font-family: var(--ds-font-family-code); } .markdown input[type='checkbox'] { margin: 0 8px 0 0; - accent-color: var(--dsw-alias-state-business-primary); + accent-color: var(--dsw-alias-label-secondary); } .tableScroll { @@ -99,22 +172,52 @@ } .tableScroll table { - width: max-content; - min-width: 100%; border-collapse: collapse; - font: var(--dsw-font-markdown-table); -} - -.tableScroll :where(th, td) { - padding: 6px 12px; - border: 1px solid var(--dsw-alias-markdown-citation); - text-align: start; - white-space: nowrap; + width: max-content; + max-width: max-content; } .tableScroll th { - background: var(--dsw-alias-markdown-code-block-banner); + text-align: start; + padding: 10px 16px; + border-bottom: 1px solid var(--dsw-alias-border-l3); + border-top: none; font: var(--dsw-font-markdown-table-head); + max-width: 320px; + max-width: min(30vw, 320px); + min-width: 100px; +} + +.tableScroll td { + padding: 10px 16px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + font: var(--dsw-font-markdown-table); + max-width: 320px; + max-width: min(30vw, 320px); + min-width: 100px; +} + +.tableScroll th:first-child, +.tableScroll td:first-child { + padding-left: 0; +} + +.tableScroll td:last-child { + padding-right: 0; +} + +.tableScroll table code { + font-size: 13px; +} + +.markdown > *:first-child, +.markdown p:first-child { + margin-top: 0 !important; +} + +.markdown > *:last-child, +.markdown p:last-child { + margin-bottom: 0 !important; } .imageAlt { diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx index a58248afab..47b0ad24fb 100644 --- a/packages/client/ui-primitives/tests/code-block.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.spec.tsx @@ -5,14 +5,17 @@ // display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx // alongside the rest of the markdown family. -import { describe, expect, it } from 'vitest' -import { cleanup, render } from '@testing-library/react' -import { afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { CodeBlock } from '../src/markdown/CodeBlock.tsx' import { highlightToHtml } from '../src/markdown/highlight.ts' afterEach(cleanup) +beforeEach(() => { + vi.useRealTimers() +}) + describe('highlightToHtml', () => { it('highlights a registered grammar into css-variables token spans', () => { const html = highlightToHtml('const x: number = 1', 'typescript') @@ -50,4 +53,86 @@ describe('CodeBlock', () => { expect(view.container.querySelector('pre.shiki')).toBeNull() expect(view.getByText('plain text')).toBeTruthy() }) + + it('shows the language banner and copies the pre textContent', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render() + expect(screen.getByText('ts')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('const a = 1') + // Flush the clipboard promise under fake timers before asserting the label. + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // While the ok label is showing, further clicks are no-ops. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('does not claim success when clipboard.writeText rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() + }) + + it('falls back to execCommand when clipboard.writeText is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + const exec = vi.fn().mockReturnValue(true) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: exec, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(exec).toHaveBeenCalledWith('copy') + expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('does not claim success when execCommand throws or is absent', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: () => { + throw new Error('denied') + }, + }) + const denied = render() + fireEvent.click(denied.getByRole('button', { name: '复制' })) + await Promise.resolve() + expect(denied.getByRole('button', { name: '复制' })).toBeTruthy() + denied.unmount() + + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: undefined, + }) + const absent = render() + fireEvent.click(absent.getByRole('button', { name: '复制' })) + await Promise.resolve() + expect(absent.getByRole('button', { name: '复制' })).toBeTruthy() + expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull() + }) }) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 05c7ce0139..07df7cebdc 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -57,8 +57,10 @@ describe('MarkdownText', () => { expect(container.querySelector('table')?.textContent).toContain('alphabeta') expect(container.querySelector('hr')).not.toBeNull() expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42') - // The ts fence routed through the shared CodeBlock: shiki token spans present. + // The ts fence routed through the shared CodeBlock: shiki token spans + banner. expect(container.querySelector('pre.shiki')).not.toBeNull() + expect(screen.getByText('ts')).toBeTruthy() + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() expect(container.querySelector('br')).not.toBeNull() expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank') expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 3f0e2659dc..bc9349de7f 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -1,6 +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 -README.md: 5c9794d4f5faea42861f47423d4e8980cbf89216 -README.zh.md: 2e0f76133ee38ba2fdc385e7fbb2a4a03b733cfa +# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md +README.md: 1227df357cb93241fcf28da9b74d7ba15207e9c5 +README.zh.md: cd87ede7264c8d47dd780acaa11128e83d7862f9 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 5c9794d4f5..1227df357c 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8. +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 2e0f76133e..cd87ede726 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为内联变量)。契约:api-contracts v3 §8。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-theme/src/styles/base.css b/packages/client/ui-theme/src/styles/base.css index 2d1acde71d..4c801b8d4d 100644 --- a/packages/client/ui-theme/src/styles/base.css +++ b/packages/client/ui-theme/src/styles/base.css @@ -9,5 +9,7 @@ --ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei'; --ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ds-transition-duration: 0.2s; + --ds-transition-duration-fast: 0.1s; --ds-transition-duration-slow: 0.3s; } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index d0f2d0a20a..88638b9e35 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -1,6 +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 -README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33 -README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010 +# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md +README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259 +README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index e0247b3e26..b5a78c30dd 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No Workspace rename/delete controls** — the picker supports selection and creation only. +- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. - **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 92ef463faa..904543a48f 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -18,5 +18,5 @@ ## 已知限制与暂缓事项 -- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。 +- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 - **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index d6375cb698..c03d511c92 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -258,6 +258,16 @@ color: var(--dsw-alias-state-error-primary); } +.deleteAction:not(:disabled) { + color: var(--dsw-alias-state-error-primary); +} + +.deleteStatus { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + @media (prefers-reduced-motion: reduce) { .wide { animation: none; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0dc6485929..c56de93c56 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -87,10 +87,15 @@ type SessionTreeProps = Pick< query: string /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void + /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ + onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { +function SessionTree({ + useSessions, startSession, open, workspaces, query, + onRenameRequest, onDeleteRequest, insertSessionBefore, +}: SessionTreeProps) { const list = useSessions((s) => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) @@ -128,11 +133,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen onCreate={() => { if (group.workspaceId !== undefined) startSession(group.workspaceId) }} - onRename={group.workspaceId === undefined + actions={group.workspaceId === undefined ? undefined - : () => { - /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ - if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + : { + rename: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }, + delete: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) + }, }} /> {group.sessions.map((node, index) => { @@ -236,6 +247,7 @@ export function WorkspaceBrowser({ startSession, open, renameWorkspace, + deleteWorkspace, insertSessionBefore, createWorkspace, }: WorkspaceBrowserProps) { @@ -291,6 +303,41 @@ export function WorkspaceBrowser({ }) } + // Delete dialog is separate from the row so a successful removal can + // unmount that row without tearing down the in-flight confirmation state. + const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) + const [deleting, setDeleting] = useState(false) + const [deleteCommittedId, setDeleteCommittedId] = useState(null) + const [deleteError, setDeleteError] = useState(null) + useEffect(() => { + if (deleteCommittedId === null + || workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return + setDeleting(false) + setDeleteCommittedId(null) + setDeleteTarget(null) + }, [deleteCommittedId, workspaces]) + const closeDelete = () => { + if (deleting) return + setDeleteTarget(null) + setDeleteError(null) + } + const confirmDelete = () => { + /* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */ + if (deleting || deleteTarget === null) return + setDeleting(true) + setDeleteCommittedId(null) + setDeleteError(null) + deleteWorkspace(deleteTarget.workspaceId).then(() => { + // Keep the confirmation pending until this component has rendered the + // committed list projection without the deleted id. Closing earlier + // exposes one stale React frame to the next Create Workspace gesture. + setDeleteCommittedId(deleteTarget.workspaceId) + }).catch((reason: unknown) => { + setDeleting(false) + setDeleteError(reason instanceof Error ? reason.message : String(reason)) + }) + } + return (
    @@ -382,6 +429,10 @@ export function WorkspaceBrowser({ setRenameDraft(currentTitle) setRenameError(null) }} + onDeleteRequest={(workspaceId, title) => { + setDeleteTarget({ workspaceId, title }) + setDeleteError(null) + }} /> ))}
    @@ -416,6 +467,30 @@ export function WorkspaceBrowser({ )} {renameError !== null &&
    {renameError}
    } + + + + + )} + > + {deleting &&
    Deleting workspace…
    } + {deleteError !== null &&
    {deleteError}
    } +
    ) } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 2be39875bc..99ed2831a6 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({ const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(null) const normalizedWorkspaceName = workspaceName.trim() - const duplicateWorkspaceName = normalizedWorkspaceName !== '' + const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) const items: MenuEntry[] = [ diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 6008da553f..2e4c88dd6b 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -32,6 +32,8 @@ export type WorkspaceBrowserInjected = { open: (sessionId: SessionId) => void /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise + /** Delete only a Host Workspace registration; directory and Session logs remain. */ + deleteWorkspace: (workspaceId: WorkspaceId) => Promise /** * Reorder a session inside its Workspace account (DOM-insertBefore * semantics: omitted anchor appends to the end). The view refreshes from diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index a444464441..98adfecce3 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -39,6 +39,7 @@ export function apply(ctx: ClientContext): void { startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, + deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, diff --git a/packages/client/ui-workspace/src/client/index.ts.orig b/packages/client/ui-workspace/src/client/index.ts.orig deleted file mode 100644 index 7b5823cc39..0000000000 --- a/packages/client/ui-workspace/src/client/index.ts.orig +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills - * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), - * and WorkspacePicker fills the conversation hero's picker hole - * (`conversation.hero.workspace` — both hero forms). Both read real Host - * Workspaces through the global useWorkspaces hook. Export discipline: - * packages/client/AGENTS.md. - */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' -import { createWorkspaceViewStore } from './stores.ts' -import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' -import { WorkspacePicker } from './WorkspacePicker.tsx' - -export type { - WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, -} from './contract/slots.ts' - -/** - * Required services (cordis fiber inject). The target slots are declared by - * the ui-sidebar / ui-conversation applies, whose activation order relative - * to this one is NOT constrained: dshClient.inject edges are informational - * (loading/prefetch metadata, never apply sequencing) and neither owner - * provides a waitable service. apply therefore registers via - * declaration-aware deferral instead of assuming order. - */ -export const inject = ['slots', 'sessions', 'workspaces'] - -/** - * Register the browser and picker once their slot declarations are on the - * ledger. Inject factories return plain callbacks; data reads use the - * framework's global hooks. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - const browserInjected = (): WorkspaceBrowserInjected => ({ - // With a workspace: materialize (reuse-or-create the blank session) and - // navigate. Without one: clear the selection — the layout's empty seat - // shows the New Session pure view state and the user picks there. - startSession: (workspaceId) => { - if (workspaceId === undefined) { - ctx.sessions.clear() - return - } - void ctx.workspaces.connectWorkspace(workspaceId).then( - (sessionId) => { ctx.sessions.open(sessionId) }, - (reason: unknown) => { console.warn('new session failed:', reason) }, - ) - }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, - renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, - insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { - await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) - }, - createWorkspace: input => ctx.workspaces.create(input), - }) - const pickerInjected = (): WorkspacePickerInjected => ({ - createWorkspace: input => ctx.workspaces.create(input), - }) - // Declaration-aware registration: each owner's declaring apply may activate - // after this one (entry activation order is unconstrained), and a register - // into an undeclared slot throws. Register once the declaration is on the - // ledger; the subscription also re-registers after an HMR collapse - // re-declares the slot (the cascade disposed our entry with it). - ctx.effect(() => { - const registrations = [ - { - name: 'sidebar.workspaces' as const, - component: WorkspaceBrowser, - register: () => ctx.slots.register( - { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, - WorkspaceBrowser, - ), - }, - { - name: 'conversation.hero.workspace' as const, - component: WorkspacePicker, - register: () => ctx.slots.register( - { name: 'conversation.hero.workspace', inject: pickerInjected }, - WorkspacePicker, - ), - }, - ] - const disposers = new Map void>() - const tryRegister = (entry: (typeof registrations)[number]): void => { - if (ctx.slots.spec(entry.name) === undefined) return - if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return - disposers.set(entry.name, entry.register()) - } - const unsubscribers = registrations.map(entry => - ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) - for (const entry of registrations) tryRegister(entry) - return () => { - for (const unsubscribe of unsubscribers) unsubscribe() - for (const dispose of disposers.values()) dispose() - } - }, 'ui-workspace: browser + picker registrations') -} diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index a100da83e9..e245f8b217 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -3,7 +3,7 @@ * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only * except workspace Rename; the session hover card is suppressed while a menu - * is open. + * is open. Workspace Rename/Delete are wired; session actions remain visual-only. */ import { useState } from 'react' import clsx from 'clsx' @@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [ * @param props.onCreate - start a frontend Session inside this Workspace. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions }: { group: GroupNode onToggle: () => void onCreate: () => void - /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */ - onRename?: (() => void) | undefined + /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ + actions?: { rename: () => void; delete: () => void } | undefined }) { const row = group const active = group.expanded && group.containsCurrent @@ -68,15 +68,19 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { {count} - {onRename !== undefined && ( + {actions !== undefined && ( { setMenuOpen(false) }} items={WORKSPACE_MENU_ITEMS} onSelect={(id) => { setMenuOpen(false) - if (id === 'rename') onRename() - // Delete is visual-only for now. + // Unknown ids leave before the dispatch: a future menu row must + // not inherit the destructive branch as an else fallback. + /* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */ + if (id !== 'rename' && id !== 'delete') return + if (id === 'rename') actions.rename() + else actions.delete() }} portal closeOnPointerLeave diff --git a/packages/client/ui-workspace/src/client/tree.ts.orig b/packages/client/ui-workspace/src/client/tree.ts.orig deleted file mode 100644 index 6d3126fcd1..0000000000 --- a/packages/client/ui-workspace/src/client/tree.ts.orig +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Derives the workspace browser tree from Host Workspace order and membership. - * Unassigned Sessions trail under Ungrouped; blank Sessions remain visible. - */ -import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' - -/** Group key for Sessions outside every Workspace. */ -export const UNGROUPED_KEY = '' - -/** Display label for the ungrouped bucket row. */ -export const UNGROUPED_LABEL = 'Ungrouped' - -/** One session node of a group's visible tree (34px row; children render indented one step). */ -export interface SessionNode { - id: SessionId - title: string - /** Visible children, already expansion/search-filtered (empty when folded). */ - children: readonly SessionNode[] - /** The session HAS children in the data (the twist renders even while folded). */ - hasChildren: boolean - expanded: boolean - running: boolean - updatedAt: number -} - -/** One workspace group section: header row facts + the visible session tree. */ -export interface GroupNode { - /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ - key: string - /** Backing Workspace id; absent only for the ungrouped bucket. */ - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - /** Total visible sessions in the group. */ - sessionCount: number - expanded: boolean - /** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */ - containsCurrent: boolean - /** Visible roots (empty while the group is folded). */ - sessions: readonly SessionNode[] -} - -/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */ -export interface TreeView { - expandedProjects: readonly string[] - expandedSessions: readonly string[] - query: string -} - -interface Group { - key: string - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - summaries: Map - roots: SessionId[] - children: Map -} - -/** - * Directory display label: basename of the path (both separators accepted). - * Ungrouped-bucket fallback for surfaces without a workspace title. - * @param cwd - directory path, or undefined for the ungrouped bucket. - * @returns basename, the raw cwd when it has no basename, or the ungrouped label. - */ -export function projectLabel(cwd: string | undefined): string { - if (cwd === undefined || cwd === '') return UNGROUPED_LABEL - const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() - return base !== undefined && base !== '' ? base : cwd -} - -/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */ -function byRecency(a: SessionSummary, b: SessionSummary): number { - if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt - return a.id < b.id ? -1 : 1 -} - -/** Build one group's parent/child tree from an ordered member list. */ -function buildGroup( - key: string, - workspaceId: WorkspaceId | undefined, - cwd: string | undefined, - label: string, - members: readonly SessionSummary[], - order: 'account' | 'recency', -): Group { - const summaries = new Map(members.map(m => [m.id, m])) - const children = new Map() - const roots: SessionSummary[] = [] - for (const m of members) { - // A session is a tree child only when its parent lives in the same - // group; cross-group or unknown parents degrade to group roots. - if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) { - const kids = children.get(m.parentId) - if (kids === undefined) children.set(m.parentId, [m.id]) - else kids.push(m.id) - } else { - roots.push(m) - } - } - // Workspace order is the member iteration order (workspace.sessionIds), so - // attached groups keep insertion order; Ungrouped sorts by recency. - if (order === 'recency') { - roots.sort(byRecency) - for (const kids of children.values()) { - kids.sort((a, b) => { - const sa = summaries.get(a) - const sb = summaries.get(b) - /* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */ - if (sa === undefined || sb === undefined) return 0 - return byRecency(sa, sb) - }) - } - } - const rootIds = roots.map(r => r.id) - // parentId cycles (host bug) leave members unreachable from any root; - // surface them as extra roots — the flatten walk's visited set stops - // loops. Each node sits in at most one kids list and roots have no - // in-group parent, so the scan pushes every reachable node exactly once. - const reachable = new Set(rootIds) - const stack = [...rootIds] - while (stack.length > 0) { - const top = stack.pop() - /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */ - if (top === undefined) break - for (const kid of children.get(top) ?? []) { - reachable.add(kid) - stack.push(kid) - } - } - for (const m of members) { - if (!reachable.has(m.id)) rootIds.push(m.id) - } - return { key, workspaceId, cwd, label, summaries, roots: rootIds, children } -} - -/** - * Group Sessions by Host Workspace: one group per entity in stable Host - * order, with members resolved from sessionIds in their stored order. Sessions - * outside every Workspace trail in the recency-ordered Ungrouped bucket. - */ -function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { - const groups: Group[] = [] - const accounted = new Set() - for (const workspace of workspaces) { - const members: SessionSummary[] = [] - for (const id of workspace.sessionIds) { - const summary = list.byId[id] - if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands - accounted.add(id) - members.push(summary) - } - groups.push(buildGroup( - workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account', - )) - } - const stray = list.ids - .map(id => list.byId[id]) - .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id)) - if (stray.length > 0) { - groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) - } - return groups -} - -function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode { - return { - id: s.id, - title: s.displayTitle, - children, - hasChildren, - expanded, - running: s.running, - updatedAt: s.updatedAt, - } -} - -function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = g.children.get(id) ?? [] - const expanded = expandedSessions.has(id) - const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : [] - return sessionNode(s, children, kids.length > 0, expanded) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** Matched sessions plus their ancestor chains (forced visible under search). */ -function searchVisible(g: Group, q: string): Set { - const visible = new Set() - for (const m of g.summaries.values()) { - if (!m.displayTitle.toLowerCase().includes(q)) continue - let cur: SessionSummary | undefined = m - while (cur !== undefined && !visible.has(cur.id)) { - visible.add(cur.id) - cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined - } - } - return visible -} - -function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id) || !visible.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - const children = kids.map(walk).filter((n): n is SessionNode => n !== null) - return sessionNode(s, children, kids.length > 0, kids.length > 0) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** - * Derive the nested workspace browser group structure. - * - * Normal mode: every group shows; sessions populate under expanded groups, - * descending only into expanded sessions. Search mode (non-blank query, - * case-insensitive display-title substring): expansion state is ignored — - * matched sessions and their ancestor chains are forced visible, groups - * without a display-title or label hit are dropped, and a label-only hit - * keeps the bare group header. Blank sessions are excluded everywhere. - * @param list - sessions list snapshot (`current` feeds containsCurrent). - * @param workspaces - real workspaces in stable Host order. - * @param view - local expansion arrays and search query. - * @returns group sections in render order. - */ -export function deriveGroups( - list: SessionListState, - workspaces: readonly WorkspaceView[], - view: TreeView, -): GroupNode[] { - const q = view.query.trim().toLowerCase() - const expandedProjects = new Set(view.expandedProjects) - const expandedSessions = new Set(view.expandedSessions) - const currentGroup = list.current === undefined - ? undefined - : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces)) { - if (q === '') { - const expanded = expandedProjects.has(g.key) - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded, - containsCurrent: g.key === currentGroup, - sessions: expanded ? buildVisible(g, expandedSessions) : [], - }) - } else { - const visible = searchVisible(g, q) - if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded: visible.size > 0, - containsCurrent: g.key === currentGroup, - sessions: buildSearch(g, visible), - }) - } - } - return groups -} - -/** - * Derive the flat session list ("In one list" mode): every session — fork - * children included — as a top-level row, strictly newest-first. No grouping, - * no parent/child adjacency; rows reuse SessionNode with children always - * empty so the renderer stays branch-free. Search mode filters by - * case-insensitive display-title substring. - * @param list - sessions list snapshot. - * @param view - the search query (expansion state does not apply). - * @returns flat rows in render order. - */ -export function deriveFlat(list: SessionListState, view: Pick): SessionNode[] { - const q = view.query.trim().toLowerCase() - const rows: SessionSummary[] = [] - for (const id of list.ids) { - const s = list.byId[id] - if (s === undefined) continue - if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue - rows.push(s) - } - rows.sort(byRecency) - return rows.map(s => sessionNode(s, [], false, false)) -} - -/** - * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). - * @param updatedAt - epoch ms of the session's last activity. - * @param now - current epoch ms (injected for pure rendering). - * @returns the row's trailing time label. - */ -export function formatRelativeTime(updatedAt: number, now: number): string { - const MIN = 60_000 - const HOUR = 3_600_000 - const DAY = 86_400_000 - const diff = Math.max(0, now - updatedAt) - if (diff < MIN) return 'now' - if (diff < HOUR) return `${Math.floor(diff / MIN)}min` - if (diff < DAY) return `${Math.floor(diff / HOUR)}h` - if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d` - if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo` - return `${Math.floor(diff / (365 * DAY))}y` -} diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 70cfb36940..6ccd923793 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -98,12 +98,16 @@ describe('workspace browser rows', () => { it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { const onRename = vi.fn() + const onDelete = vi.fn() const onToggle = vi.fn() const group: GroupNode = { key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], } - render() + render() fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) // Opening the menu neither toggles the group nor renames yet. expect(onToggle).not.toHaveBeenCalled() @@ -111,11 +115,11 @@ describe('workspace browser rows', () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) expect(onRename).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() - // Delete stays visual-only: selecting it just closes the menu. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) expect(screen.queryByRole('menu')).toBeNull() expect(onRename).toHaveBeenCalledOnce() + expect(onDelete).toHaveBeenCalledOnce() // Escape closes without selecting (Menu onClose path). fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) fireEvent.keyDown(document, { key: 'Escape' }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e9b55e7b76..33abdc1231 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -54,6 +54,7 @@ function mount(overrides: Partial = {}) { startSession: vi.fn(), open: vi.fn(), renameWorkspace: vi.fn(async () => {}), + deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), ...overrides, @@ -457,6 +458,79 @@ describe('WorkspaceBrowser', () => { await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) }) + it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => { + let resolveDelete!: () => void + const deleteWorkspace = vi.fn(() => new Promise((resolve) => { resolveDelete = resolve })) + const browser = mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])), + deleteWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + const dialog = screen.getByRole('dialog', { name: 'Delete workspace' }) + expect(dialog.textContent).toContain('removes “Alpha” from the workspace list') + expect(dialog.textContent).toContain('folder and session logs will be kept') + expect(dialog.textContent).toContain('sessions will appear under Ungrouped') + + const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement + fireEvent.click(confirm) + fireEvent.click(confirm) + expect(deleteWorkspace).toHaveBeenCalledOnce() + expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha')) + expect(confirm.disabled).toBe(true) + expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByRole('status').textContent).toBe('Deleting workspace…') + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + await act(async () => { resolveDelete() }) + // RPC success alone does not close: the component waits until its + // useWorkspaces projection has committed the removal, preventing a stale + // duplicate-name frame from leaking into the next create gesture. + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + rerender(browser, { useWorkspaces: hook(workspaceState([])) }) + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + + it('keeps the delete dialog open on failure and allows retry or cancellation', async () => { + const deleteWorkspace = vi.fn() + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockRejectedValueOnce('denied') + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + deleteWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') }) + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + + it('Cancel, Escape, and Close dismiss deletion without calling the action', () => { + const deleteWorkspace = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + deleteWorkspace, + }) + const open = () => { + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + } + open() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + open() + fireEvent.keyDown(document, { key: 'Escape' }) + open() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(deleteWorkspace).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + it('search hides drag affordances (rows are not draggable during search)', () => { const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) mount({ diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index d487fae5f8..a5510178e7 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } { function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) { const onPick = vi.fn() const onClose = vi.fn() - const view = render( + const anchorRef = anchor() + const renderPicker = (nextItems: readonly WorkspaceView[]) => ( , + /> ) - return { view, onPick, onClose, createWorkspace } + const view = render( + renderPicker(items), + ) + return { + view, onPick, onClose, createWorkspace, + rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, + } } function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void { @@ -106,6 +113,22 @@ describe('WorkspacePicker', () => { expect(b.createWorkspace).not.toHaveBeenCalled() }) + it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => { + let resolve!: (workspace: WorkspaceView) => void + const pending = new Promise((settle) => { resolve = settle }) + const created = workspace('fresh', 'same-name') + const b = mount([], vi.fn(() => pending)) + chooseCreateItem('Create a new workspace') + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + + b.rerenderItems([created]) + expect(screen.getByRole('status').textContent).toBe('Creating workspace…') + expect(screen.queryByRole('alert')).toBeNull() + await act(async () => { resolve(created); await pending }) + expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) + }) + it('exposes creation phase and error text while retaining the modal for retry', async () => { let reject!: (reason: unknown) => void const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 4c6098a2ec..5a09dd69f2 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) process.exit(0) ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 10c64d10ca..7920197739 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -272,7 +272,7 @@ export class BasicCompactService extends CompactService { return this.compactRegion(range.start, range.end, agent, signal) } - const context = await this.ctx.llm.resolveModelContext(target.provider, target.model) + const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context const targetKey = `${target.provider}/${target.model}` if (context === undefined) { throw new TargetPressureConfigError( diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1db86d38e4..b89a9d1950 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -16,7 +16,7 @@ import type { ContentBlock, GenerateOptions, LlmFailure, - LlmModelContext, + LlmResolvedModelInfo, Message, StreamChunk, } from '@deepseek-ai/dsh-llm' @@ -33,8 +33,13 @@ class ContextAdapter extends LlmAdapter { super() } - override resolveModelContext(): Promise { - return Promise.resolve({ contextWindow: this.contextWindow }) + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + context: { contextWindow: this.contextWindow }, + }) } override async * stream(): AsyncIterable { @@ -47,9 +52,14 @@ class RoutedContextAdapter extends LlmAdapter { super() } - override resolveModelContext(provider: string): Promise { + override resolveModel(provider: string, model: string): Promise { const contextWindow = this.windows[provider] - return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + return Promise.resolve({ + provider, + id: model, + name: model, + ...contextWindow === undefined ? {} : { context: { contextWindow } }, + }) } override async * stream(): AsyncIterable { @@ -453,6 +463,18 @@ describe('pressure measurement and retention', () => { .resolves.not.toBeNull() }) + it('forwards turn cancellation to proactive model metadata resolution', async () => { + const ctx = createContext() + const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo') + const compact = service(compactConfig, ctx) + const session = conversation() + const signal = new AbortController().signal + + await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', signal)) + .resolves.not.toBeNull() + expect(resolveModelInfo).toHaveBeenCalledWith(MODEL, MODEL, signal) + }) + it('re-resolves capacity after a same-model-id provider switch in one session', async () => { const ctx = new Context() void new LlmService(ctx) @@ -485,7 +507,11 @@ describe('pressure measurement and retention', () => { void new LlmService(ctx) void new TokenMeterService(ctx) ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000)) - vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({ + provider, + id: model, + name: model, + })) const compact = service(compactConfig, ctx) const session = conversation(4) session.append('request/header', { @@ -1337,7 +1363,11 @@ describe('automatic listener and loader composition', () => { const ctx = createContext() const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn - vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({ + provider, + id: model, + name: model, + })) void new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 83c18f78b4..429bbac3e4 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -41,8 +41,13 @@ class StepwiseToolAdapter extends LlmAdapter { super() } - override resolveModelContext(): Promise<{ contextWindow: number }> { - return Promise.resolve({ contextWindow: 400 }) + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + context: { contextWindow: 400 }, + }) } async * stream(_options: GenerateOptions): AsyncIterable { @@ -76,8 +81,13 @@ class OverflowRecoveryAdapter extends LlmAdapter { super() } - override resolveModelContext(): Promise<{ contextWindow: number }> { - return Promise.resolve({ contextWindow: 128 }) + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + context: { contextWindow: 128 }, + }) } override async * stream(options: GenerateOptions): AsyncIterable { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224e8300ab..b4a9467cff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -381,12 +381,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', }, { - signature: 'async resolveModelContext( provider: string, model: string, ): Promise', - jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */', + signature: 'async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise', + jsDoc: '/**\n * Resolve and validate all metadata from the adapter that owns one exact\n * route. The result is detached from adapter-owned objects; catalog\n * membership remains advisory and does not control request routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @param signal - optional cancellation for adapter-owned asynchronous lookup.\n * @returns exact model identity plus available context and reasoning metadata.\n */', + }, + { + signature: 'async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Validate a conversation call config against its exact model capability and\n * materialize an adapter-configured default. Unsupported explicit efforts\n * reject before provider I/O; no clamping or aliasing is performed. This\n * standalone query does not bind a later dispatch; use {@link prepareCall}\n * when logging and streaming must share one adapter registration.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a detached config only when a default must be materialized.\n */', + }, + { + signature: 'async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Resolve one call under its current adapter registration. The returned\n * one-shot handle keeps that registration across header logging and dispatch,\n * so HMR cannot combine one adapter\'s capability result with another adapter.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a prepared config and its registration-bound stream entry point.\n */', }, { signature: 'stream(options: GenerateOptions): AsyncIterable', - jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', + jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', }, ], }, @@ -744,6 +752,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'subprocess', + summary: 'Abstract subprocess service.', + methods: [ + { + signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', + jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.\n * @returns the live process handle (streams/readers, signalling, outcome promise).\n */', + }, + ], + }, { key: 'systemPrompt', summary: 'Registry service for the prompt inputs assembled before each model step.', @@ -942,6 +960,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'list(): Workspace[]', jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', }, + { + signature: 'delete(id: WorkspaceId): Promise', + jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', + }, { signature: 'async resolveByPath(path: string): Promise', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', @@ -1653,7 +1675,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'GenerateOptions', - declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}', + declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}', }, { name: 'GenericCallView', @@ -1733,11 +1755,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmAdapter', - declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModelContext(_provider: string, _model: string): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'LlmCallConfig', - declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', + declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, { name: 'LlmFailure', @@ -1751,10 +1773,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', }, + { + name: 'LlmModelReasoningInfo', + declaration: 'export interface LlmModelReasoningInfo {\n efforts: readonly LlmReasoningEffortInfo[];\n defaultEffort?: ReasoningEffortId;\n}', + }, { name: 'LlmProviderInfo', declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n}', }, + { + name: 'LlmReasoningEffortInfo', + declaration: 'export interface LlmReasoningEffortInfo {\n id: ReasoningEffortId;\n name: string;\n description?: string;\n}', + }, + { + name: 'LlmResolvedModelInfo', + declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n reasoning?: LlmModelReasoningInfo;\n}', + }, { name: 'Message', declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n}', @@ -1779,6 +1813,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OutOfBandSessionEventType', declaration: 'export type OutOfBandSessionEventType = Exclude, SurfaceEventType>;', }, + { + name: 'PreparedLlmCall', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', + }, { name: 'PreparedReferencedMessage', declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n contexts: HookContext[];\n}', @@ -1899,6 +1937,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', }, + { + name: 'ReasoningEffortId', + declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;', + }, { name: 'RequestHeaderReason', declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', @@ -2231,6 +2273,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentStopReasonMap', declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}', }, + { + name: 'SubprocessCollect', + declaration: 'export interface SubprocessCollect {\n maxBytes: number;\n spill?: {\n maxBytes: number;\n };\n}', + }, + { + name: 'SubprocessCollectedOutputs', + declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}', + }, + { + name: 'SubprocessHandle', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', + }, + { + name: 'SubprocessOutcome', + declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n}', + }, + { + name: 'SubprocessOutputMode', + declaration: 'export type SubprocessOutputMode = \'pipe\' | \'inherit\' | SubprocessCollect;', + }, + { + name: 'SubprocessOutputRead', + declaration: 'export interface SubprocessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}', + }, + { + name: 'SubprocessOutputReader', + declaration: 'export interface SubprocessOutputReader {\n readFrom(fromByte: number): SubprocessOutputRead;\n}', + }, + { + name: 'SubprocessSpawnSpec', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n}', + }, + { + name: 'SubprocessStdinMode', + declaration: 'export type SubprocessStdinMode = \'ignore\' | \'pipe\' | {\n readonly data: string;\n};', + }, + { + name: 'SubprocessStdio', + declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}', + }, { name: 'SurfaceEvent', declaration: 'export type SurfaceEvent = SessionEvent & {\n surfaceOp: SurfaceOp;\n};', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 41dccf8371..9af1ca9fdc 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -1,6 +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 -README.md: 3acf4d3828291d5f318306f2652e0d920c695675 -README.zh.md: 11ff8318813b1abd096e1a3549d389cbba88f12b +# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md +README.md: 6ad982ffff7b73e16ee39f9c29da787e37547de4 +README.zh.md: c72df198774f0f8009cc5ab43932e69187757745 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 3acf4d3828..6ad982ffff 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -62,6 +62,8 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. +After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. + Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 11ff831881..c72df19877 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -62,6 +62,8 @@ interface Config { 每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。成功的 `agent/step-result` 存储其转换后内容;被拒绝的结果会先记录空内容,再继续抛出原始失败。该锚点保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时保留用量;空内容不会进入派生消息历史。 +在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 + 插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败,以及带内的终止错误或中止结束原因,才进入 `agent/request-error`;中间件、结果处理、工具和 `agent/post-step` 仍属于普通轮次失败。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实和不可变的先前失败。重试会在新的编号步骤中根据持久日志重建;成功会清除连续失败历史;耗尽后只在 `turn/end` 上记录一次结构化失败。AgentLoop 私下拥有一个取消持有者,其显式信号覆盖提示词策略、组装、每个步骤、模型与工具工作、恢复、continuation 和终止停止;它会在发布 `turn/end` 前立即退役该持有者,而驱动器可以在持久性 flush 期间继续保持 `running`。有效的 `cancel()` 会先发出仅存在于运行时的类型化 `user | parent` 原因,再清除待处理工作,并以协作方式中止该持有者;通知失败无法 veto 取消,通知观察方排队的工作会被清除,之后由中止观察方排队的工作属于下一轮次,空闲取消则不发出任何内容。持久 `turn/end` 仍使用粗粒度的 `aborted`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。释放会在终止分类中胜出;忽略信号的工作必须先结算,系统才能完全停稳。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。终止 continuation 的停止决定在轮次关闭和持久性 flush 期间始终具有权威性。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用、drain 已启动的结果,然后在轮次通过普通中止路径关闭前,drain 已接纳的批次上下文。 diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 4324deca8d..2484cd5793 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent' @@ -661,19 +661,45 @@ async function runStep( // Seed the first request from agent options and later requests from the logged header; // detach and freeze so listeners must return an attributable replacement. - const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log - ? session.requestHeader()!.config - : { provider: options.provider ?? '', model: options.model ?? '' })) + const loggedConfig = session.requestHeader()?.config + const initialProvider = options.provider ?? '' + const initialModel = options.model ?? '' + const initialConfig: LlmCallConfig = { + provider: initialProvider, + model: initialModel, + ...loggedConfig?.provider === initialProvider + && loggedConfig.model === initialModel + && loggedConfig.reasoningEffort !== undefined + ? { reasoningEffort: loggedConfig.reasoningEffort } + : {}, + } + const seedConfig: LlmCallConfig = deepFreeze(structuredClone( + transmission.loggedHeader + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log + ? session.requestHeader()!.config + : initialConfig, + )) // Listener replacements are recorded in the request header before dispatch. - const config = await events.waterfall( + const proposedConfig = await events.waterfall( 'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig), ) interruptionCheckpoint(signal) - if (!config.provider || !config.model) { + if (!proposedConfig.provider || !proposedConfig.model) { throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) } + let config: LlmCallConfig + let preparedCall: PreparedLlmCall | undefined + try { + preparedCall = await ctx.llm.prepareCall(proposedConfig, signal) + config = preparedCall.config + } catch (error: unknown) { + // A waterfall listener may own and short-circuit a route with no adapter. + // Terminal dispatch still raises NO_ADAPTER when no listener handles it. + if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error + config = proposedConfig + } + interruptionCheckpoint(signal) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call const sessionPrefix = transmission.sessionPrefix! @@ -691,6 +717,9 @@ async function runStep( const request: GenerateOptions = markAgentLoopRequest(deepFreeze({ provider: header.config.provider, model: header.config.model, + ...header.config.reasoningEffort !== undefined + ? { reasoningEffort: header.config.reasoningEffort } + : {}, messages: [...header.messagePrefix ?? [], ...boundaryMessages], ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, @@ -704,7 +733,7 @@ async function runStep( // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() const chunkSeqs: number[] = [] - const stream = ctx.llm.stream(request) + const stream = preparedCall?.stream(request) ?? ctx.llm.stream(request) try { for await (const chunk of stream) { interruptionCheckpoint(signal) diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 4aaff72415..e754f1bd90 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -1,4 +1,4 @@ -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' /** Helpers to write scripted responses tersely. */ @@ -64,10 +64,25 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] - constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) { + constructor( + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], + private readonly reasoning?: LlmModelReasoningInfo, + ) { super() } + override resolveModel( + provider: string, + model: string, + ): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, + }) + } + async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) const entry = this.script.shift() diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index a73218f345..71fc9ea981 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -7,8 +7,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' @@ -104,6 +104,168 @@ describe('request stability across the loop', () => { expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) }) + it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => { + const reasoning = { + efforts: [ + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + } + const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request', async (_agent, turn, _step, _config, _signal, next) => { + const config = await next() + return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([ + ReasoningEffortId('high'), + ReasoningEffortId('max'), + ]) + const headers = agent.session.events.filter(event => event.type === 'request/header') + expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([ + ReasoningEffortId('high'), + ReasoningEffortId('max'), + ]) + expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change']) + + const resumedAdapter = new MockAdapter([textResponse('three')], reasoning) + const resumedCtx = await harness(resumedAdapter) + const resumedHandle = await resumedCtx.agents.create({ + sessionId: SessionId('effort-resumed'), + seed: structuredClone(agent.session.events), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + send(resumedHandle.agent, 'third') + await waitForIdle(resumedCtx, resumedHandle.agent) + + expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('max')) + const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header') + expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('max')) + expect(resumedHeaders.at(-1)?.data.reason).toBe('resume') + }) + + it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: 'stable base' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + const started = Promise.withResolvers() + const reasoning = Promise.withResolvers() + const first = new class extends MockAdapter { + override async resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise { + started.resolve(undefined) + return { + provider, + id: model, + name: model, + reasoning: await reasoning.promise, + } + } + }([textResponse('first')]) + const second = new MockAdapter([textResponse('second')], { + efforts: [{ id: ReasoningEffortId('max'), name: 'Max' }], + defaultEffort: ReasoningEffortId('max'), + }) + const disposeFirst = ctx.llm.registerAdapter(['mock'], first) + const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await started.promise + disposeFirst() + ctx.llm.registerAdapter(['mock'], second) + reasoning.resolve({ + efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], + defaultEffort: ReasoningEffortId('high'), + }) + await waitForIdle(ctx, agent) + + expect(first.requests.map(request => request.reasoningEffort)).toEqual([ + ReasoningEffortId('high'), + ]) + expect(second.requests).toHaveLength(0) + const headers = agent.session.events.filter(event => event.type === 'request/header') + expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high')) + }) + + it('aborts a blocked reasoning lookup before quiescent disposal completes', async () => { + const started = Promise.withResolvers() + const adapter = new class extends MockAdapter { + override resolveModel( + _provider: string, + _model: string, + signal?: AbortSignal, + ): Promise { + if (signal === undefined) return Promise.reject(new Error('missing reasoning signal')) + started.resolve(signal) + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted')) + return + } + signal.addEventListener('abort', () => { + reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted')) + }, { once: true }) + }) + } + }([]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('reasoning-dispose'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + + send(handle.agent, 'go') + const signal = await started.promise + await handle.dispose() + + expect(signal.aborted).toBe(true) + expect(handle.agent.status).toBe('disposed') + expect(adapter.requests).toHaveLength(0) + expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false) + }) + + it.each(['plain error', 'LLM error'] as const)( + 'does not swallow a %s from exact-model resolution', + async (kind) => { + const failure = kind === 'plain error' + ? new Error('reasoning metadata failed') + : new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT') + const adapter = new class extends MockAdapter { + override resolveModel(): Promise { + return Promise.reject(failure) + } + }([]) + const ctx = await harness(adapter) + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), { + provider: 'mock', + model: 'mock', + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(errors).toContain(failure) + expect(adapter.requests).toHaveLength(0) + }, + ) + it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) @@ -301,6 +463,7 @@ describe('request stability across the loop', () => { const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)! const header = foldRequestHeader(events.slice(0, firstChunk.seq))! expect(request.model).toBe(header.config.model) + expect(request.reasoningEffort).toBe(header.config.reasoningEffort) expect(request.system).toEqual(header.system) expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? [])) expect(request.temperature).toBe(header.config.temperature) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index c493c8d0e3..7c946e7b55 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -1,6 +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 -README.md: bbae91ff2f497208f1ce620e162c27968d666ac3 -README.zh.md: 95367b35a546c68491b9623daf45a54cd63f2731 +# pnpm run verify-translation-pairing --write packages/core/agent/README.md +README.md: a65c53b3e4edf2031f286d7d172e73357c66ff1e +README.zh.md: 05da8a0a0d3ad2ed879b72e20eae1c4efaf711c8 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bbae91ff2f..a65c53b3e4 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 95367b35a5..05da8a0a0d 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型选择,并将该对同时应用到一个步骤的提示词变量与请求路由。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 - `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber 释放。 - 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。 diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts index 18287a3ff5..e0492a71d2 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/llm-target.ts @@ -1,17 +1,19 @@ /** - * Agent-scoped provider/model target snapshot shared by interactive front doors. + * Agent-scoped LLM target snapshot shared by interactive front doors. * @module @deepseek-ai/dsh-agent/llm-target */ import type { Context } from 'cordis' -import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -/** Complete provider/model route selected for one live agent. */ +/** Complete provider/model route and optional reasoning effort selected for one live agent. */ export interface AgentLlmTarget { /** Registered provider route. */ provider: string /** Provider-owned model id. */ model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: ReasoningEffortId } /** Mutable selection plus the target captured for the current step. */ @@ -24,9 +26,11 @@ export interface AgentLlmTargetRef { /** * Couple one mutable target to agent-scoped prompt assembly and request routing. - * Prompt assembly snapshots the selected pair before delegating, then applies - * both prompt variables and request config to that snapshot so a concurrent - * switch takes effect on a later step instead of splitting the two surfaces. + * Prompt assembly snapshots the selected target before delegating, then applies + * its route to prompt variables and its route/effort to request config so a + * concurrent switch takes effect on a later step instead of splitting the two + * surfaces. An absent selected effort clears any inherited effort so a model + * switch can restore that target's provider/default behavior. * * @param agentCtx - The target agent's scoped context. * @param target - Mutable selection owned by the calling front door. @@ -52,10 +56,15 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR async (_agent, _turn, _step, _config, _signal, next): Promise => { const resolved = await next() const selected = target.assembled - return selected === undefined ? resolved : { - ...resolved, + if (selected === undefined) return resolved + const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved + return { + ...withoutInheritedEffort, provider: selected.provider, model: selected.model, + ...selected.reasoningEffort === undefined + ? {} + : { reasoningEffort: selected.reasoningEffort }, } }, ) diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts index fa4ef2b459..10d688d6ed 100644 --- a/packages/core/agent/tests/llm-target.spec.ts +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -7,7 +7,7 @@ import { type Agent, type AgentLlmTargetRef, } from '../src/index.ts' -import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm' describe('installAgentLlmTarget()', () => { it('snapshots prompt variables and request routing together, then disposes both listeners', async () => { @@ -24,16 +24,31 @@ describe('installAgentLlmTarget()', () => { 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), )).resolves.toBe(seed) - target.current = { provider: 'alpha', model: 'a1' } + target.current = { + provider: 'alpha', + model: 'a1', + reasoningEffort: ReasoningEffortId('high'), + } expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) target.current = { provider: 'beta', model: 'b1' } await expect(agentEvents(ctx, agent).waterfall( 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), - )).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 }) + )).resolves.toEqual({ + provider: 'alpha', + model: 'a1', + reasoningEffort: ReasoningEffortId('high'), + temperature: 0.2, + }) expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' }) + const inherited: LlmCallConfig = { + provider: 'alpha', + model: 'a1', + reasoningEffort: ReasoningEffortId('max'), + temperature: 0.2, + } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed), + 'agent/request', 1, 1, inherited, signal, () => Promise.resolve(inherited), )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) dispose() diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..bc6d7d6462 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -183,6 +183,11 @@ function assertCurrentLlmShape(event: Record, index: number): v const header = record['header'] const config = typeof header === 'object' && header !== null ? (header as Record)['config'] : undefined if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`) + const reasoningEffort = (config as Record)['reasoningEffort'] + if (reasoningEffort !== undefined + && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) { + throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`) + } } if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) { throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index a01f1a6a79..121c645d84 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -156,7 +156,7 @@ export interface TodoItem { * canonical empty optional fields are absent. */ export interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index b185cf6e56..e2fd4cc781 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' const CONFIG = { provider: 'mock', model: 'm' } @@ -29,6 +30,10 @@ describe('headerEquals', () => { it('compares every canonical field and preserves tool order', () => { expect(headerEquals(base, structuredClone(base))).toBe(true) expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false) + expect(headerEquals(base, { + ...base, + config: { ...base.config, reasoningEffort: ReasoningEffortId('high') }, + })).toBe(false) expect(headerEquals(base, { ...base, system: 'other' })).toBe(false) expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false) expect(headerEquals(base, { ...base, tools: [] })).toBe(false) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e649214a29..badd11956e 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' -import { CallId } from '@deepseek-ai/dsh-llm' +import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { displayPromptContent, findLastMessageTurnEnd, @@ -118,6 +118,11 @@ describe('Session', () => { }) it('renders injected-context and steering messages as plain user content', () => { + expect(displayPromptContent({ + content: [{ type: 'text', text: 'plain prompt' }], + source: { kind: 'user' }, + })).toEqual([{ type: 'text', text: 'plain prompt' }]) + const session = new Session(SessionId('s2')) session.append('user/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], @@ -228,6 +233,35 @@ describe('Session', () => { .toEqual([unrelatedPrimitiveData]) }) + it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => { + const valid = { + type: 'request/header', + seq: 0, + time: 1, + data: { + header: { + config: { + provider: 'mock', + model: 'model', + reasoningEffort: ReasoningEffortId('adapter-owned'), + }, + }, + reason: 'initial', + }, + } as const + expect(new Session(SessionId('reasoning-effort'), [valid]).events[0]) + .toEqual(valid) + + for (const reasoningEffort of ['', 1]) { + const invalid = structuredClone(valid) as unknown as SessionEvent + if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header') + const config = invalid.data.header.config as unknown as Record + config.reasoningEffort = reasoningEffort + expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid])) + .toThrow('seed request/header at index 0 has an invalid reasoningEffort') + } + }) + it('isolates the log from mutation through a derived message (append-only contract)', () => { const session = new Session(SessionId('s4')) session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 02e82ac5ac..4ab05b21ab 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -17,6 +17,7 @@ import { import { Readable, Writable } from 'node:stream' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -32,7 +33,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'acp/acp', 'examples/acp-demo', 'util/paths', @@ -94,6 +95,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', ' name: \'./mock-llm.mjs\'', + '- id: subprocess', + ' name: \'@deepseek-ai/dsh-subprocess-local\'', '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: acp-agent', @@ -209,25 +212,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n }, 30_000) }) -/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ -function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const proc = spawn(process.execPath, [acpBin, '--config', configArg], { - cwd, - env: { - ...process.env, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stderr = '' - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000) - proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - proc.stdin.end() +/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */ +async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { + const result = await execa(process.execPath, [acpBin, '--config', configArg], { + cwd, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`) + return { code: result.exitCode ?? -1, stderr: result.stderr } } diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 2c507a0bad..8bc14c7330 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -35,6 +35,8 @@ const CORDIS_YML = ` name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' - id: acp-agent diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 923a9aace6..e76db97daf 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index 11f5421bf9..46f3f56dc3 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -5,6 +5,7 @@ import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import { CallId } from '@deepseek-ai/dsh-llm' @@ -52,6 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot }) await ctx.plugin(agentSpine, { diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 5c3a6ad62e..45582b5f45 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { dirname, join } from 'node:path' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -23,7 +23,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'context/workspace-context', @@ -80,6 +80,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', " name: './mock-llm.ts'", + '- id: subprocess', + " name: '@deepseek-ai/dsh-subprocess-local'", '- id: bash', " name: '@deepseek-ai/dsh-bash-local'", '- id: cli-agent', @@ -114,36 +116,34 @@ interface BinResult { readonly stderr: string } -function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { - return new Promise((resolveResult, reject) => { - const child = spawn(process.execPath, [cliBin, ...args], { - cwd, - env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' +async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { + const subprocess = execa(process.execPath, [cliBin, ...args], { + cwd, + env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, + }) + // Genuinely custom mid-stream logic: the signal cases deliver `interrupt` + // once the first streamed chunk proves the turn is in flight. + if (interrupt !== undefined) { + let streamed = '' let interrupted = false - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdout += chunk - if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) { + subprocess.stdout.on('data', (chunk: Buffer) => { + streamed += chunk.toString('utf8') + if (!interrupted && streamed.includes('assistant/chunk')) { interrupted = true - child.kill(interrupt) + subprocess.kill(interrupt) } }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code, signal) => { - clearTimeout(timer) - resolveResult({ code: code ?? -1, signal, stdout, stderr }) - }) - }) + } + const result = await subprocess + if (result.timedOut) { + throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr } } let consumer: string | undefined diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 45de8f5e4a..bf9cf15aa0 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index c497d7e428..6be9629460 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -18,6 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal @@ -61,6 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) await ctx.plugin(ToolFsSearch) }) diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 34696ab625..4d52d1f0c5 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index f5fd5702d5..1bceaa3529 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -52,6 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -353,6 +355,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -374,6 +377,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 8a3c44c5bd..a773a1a74d 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -41,6 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath, ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -359,6 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is @@ -657,6 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -686,6 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 5d583baafe..fb598b4da3 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 923a4bf8b5..3e1e3f8f93 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -41,6 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -164,6 +166,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() @@ -186,6 +189,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index ebb2164902..1c3c8edff4 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -31,6 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -310,6 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. @@ -619,6 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index eb06e14d2d..bf9f1194d4 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -1,6 +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 -README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f -README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10 +# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md +README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1 +README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 43ad70fa8b..adcffaf553 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,9 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. + +`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cc95a7512f..957b7967c9 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,9 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 + +`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..a357eee13b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import type { Context } from 'cordis' import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' @@ -283,6 +283,15 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: return undefined } +/** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */ +function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined { + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i] + if (event !== undefined && event.type === 'todo/write') return event.data.todos + } + return undefined +} + /** * Thrown by the cold-resume path when the id names no servable session * (absent from the store, or a pre-project legacy log without a cwd). @@ -642,7 +651,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) return { event, ...view === undefined ? {} : { view } } }) - return ok(request, { events: entries, hasMore: page.hasMore }) + // Tail page carries the session-level todo projection over the FULL + // log (the page window may not contain the last todo/write; a paged + // client cannot reconstruct session-level state from it). + const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined + return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } }) }, async prompt(request) { @@ -764,6 +777,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { workspace: workspaceView(workspace) }) }, + async delete(request) { + const { workspaceId } = request.payload + const operation = workspaceCreationChain.then(() => + ctx.workspace.delete(brandWorkspaceId(workspaceId))) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + if (!await operation) return workspaceNotFound(request, workspaceId) + return ok(request, { deleted: true as const }) + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -977,8 +999,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) })) }), ctx.on('domain/changed', (change) => { - if (change.domain !== 'workspace' || change.operation !== 'put') return + if (change.domain !== 'workspace') return if (change.table === '') { + if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue @@ -991,7 +1014,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return } - if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return + if (change.table !== 'workspaces') return + if (change.operation === 'deleted') { + if (!committedWorkspaceIds.delete(change.key)) return + queue.push(frame({ + type: 'host/workspace-removed', + workspaceId: change.key as WorkspaceId, + })) + return + } + if (!committedWorkspaceIds.has(change.key)) return // Existing-entity table writes are complete attach/touch commits. // A new entity's first put waits for the global registry write above. queue.push(frame({ diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index e95b371c54..973db5a91e 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts' -import { workspaceViewSchema } from './workspace.schema.ts' +import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ export const askUserQuestionItemSchema = z.object({ @@ -47,6 +47,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), + z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index db572215cb..bf66cbf76b 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -85,7 +85,9 @@ export type MuxFrame = * agent-error is the only outlet for live failures with no turn position; * workspace-changed pushes the full new snapshot after every durable * workspace mutation (create/attach/order change — the client upserts, while - * `workspace.list` provides the reconnect baseline). + * `workspace.list` provides the reconnect baseline); workspace-removed is the + * committed registration-deletion increment and never implies directory or + * session-log deletion. */ export type HostFrame = | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } @@ -93,6 +95,7 @@ export type HostFrame = | { type: 'host/session-status'; sessionId: SessionId; running: boolean } | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } + | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } /** * The command registry changed (`commands/change` passthrough). Pure * invalidation signal, no payload: clients refetch `command.list` in the diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index abe992584c..68ccc9ec89 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -26,6 +26,7 @@ export interface RpcMethodMap { 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] + 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 12ebd4182d..9445568e98 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -93,10 +93,17 @@ export const historyEntrySchema = z.object({ view: toolEventViewSchema.optional(), }) satisfies z.ZodType> +/** One todo item of the tail page's session-level projection (the todo/write payload shape). */ +export const todoItemSchema = z.object({ + content: z.string(), + status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]), +}) + /** session.history response value. */ export const sessionHistoryValueSchema = z.object({ events: z.array(historyEntrySchema), hasMore: z.boolean(), + todos: z.array(todoItemSchema).optional(), }) satisfies z.ZodType>> /** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2552b5d5a3..e46bc43fe8 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -5,7 +5,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -77,9 +77,13 @@ export interface SessionsApi { * Each entry pairs the raw SessionEvent with the host-computed view (tool events whose * presenter produced one, evaluated against the registry at pagination time); the client * rebuilds the surface from the events with the shared fold. + * The tail page (beforeSeq absent) also carries `todos` — the session's current todo + * projection (latest `todo/write` over the FULL log, independent of the page window) — + * so a paged client restores the plan without walking history; absent when the session + * never wrote one. Older pages omit it (the projection is session-level, not per-page). */ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): - Promise> + Promise> /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 47c3ae6d59..e16e5339da 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -59,6 +59,16 @@ export const workspaceRenameValueSchema = z.object({ workspace: workspaceViewSchema, }) satisfies z.ZodType>> +/** workspace.delete request payload. */ +export const workspaceDeleteRequestSchema = z.object({ + workspaceId: workspaceIdSchema, +}) satisfies z.ZodType>> + +/** workspace.delete response value. */ +export const workspaceDeleteValueSchema = z.object({ + deleted: z.literal(true), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 6ec636126b..ff22d845fb 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -65,6 +65,14 @@ export interface WorkspaceApi { rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): Promise> + /** + * Removes one Workspace registration. The directory, every user file, and + * every session log remain untouched; those Sessions consequently become + * ungrouped. An unknown id fails with `workspace-not-found`. + */ + delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): + Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0424ba7a4f..8762670cd7 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -23,6 +23,7 @@ import { } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, + workspaceDeleteValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, @@ -60,6 +61,7 @@ export interface IApiClient { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> + delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> } commands: { @@ -91,6 +93,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.list', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), + delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index b79980d63e..3bbcbffba1 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -24,6 +24,7 @@ import { import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, + workspaceDeleteRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, @@ -57,6 +58,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, + 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 86ffa56eb4..4263c53cea 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -154,6 +154,39 @@ describe('mux live view computation', () => { expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false) }) + it('tail page carries the full-log todo projection; older pages and todo-less sessions omit it', async () => { + const { ctx } = await harness() + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + // Superseded write early in the log, latest write later; enough messages to page. + session.append('todo/write', { todos: [{ content: 'old', status: 'pending' }] }) + for (let turn = 0; turn < 6; turn++) { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn, step: 0, content: [{ type: 'text', text: `a${turn}` }], provenance: { provider: 'p', model: 'm' } }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + session.append('todo/write', { todos: [{ content: 'current', status: 'in_progress' }] }) + + // Tail page limited to 2 messages: the latest todo/write may or may not sit + // in the window — the projection must come from the FULL log either way. + const tail = await api.sessions.history({ rpcId: RpcId('t-todos'), payload: { sessionId: session.id, maxMessages: 2 } }) + if (!tail.result.ok) throw new Error('history failed') + expect(tail.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }]) + // An older page omits the projection (session-level, tail-page-only). + const boundary = tail.result.value.events[0]?.event.seq ?? 0 + const older = await api.sessions.history({ rpcId: RpcId('t-todos-2'), payload: { sessionId: session.id, beforeSeq: boundary, maxMessages: 2 } }) + if (!older.result.ok) throw new Error('older failed') + expect('todos' in older.result.value).toBe(false) + // A session with no todo/write anywhere omits the field. + const bare = ctx.sessions.create() + ctx.agents.register({ id: bare.id, session: bare, status: 'idle', ctx } as Agent) + const bareTail = await api.sessions.history({ rpcId: RpcId('t-todos-3'), payload: { sessionId: bare.id } }) + if (!bareTail.result.ok) throw new Error('bare failed') + expect('todos' in bareTail.result.value).toBe(false) + }) + it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 11cdf5795c..bbd57cb6dc 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -244,4 +244,37 @@ describe('Host Workspace increments', () => { abort.abort() expect(await next).toMatchObject({ done: true }) }) + + it('deletes the registration, keeps its session and folder, and streams one removal', async () => { + const { api, ctx } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const sessionId = SessionId('session-kept-after-workspace-delete') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const removed = nextHostFrame(stream) + expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))) + expect(await removed).toMatchObject({ + payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId }, + }) + expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + expect(ctx.agents.get(sessionId)).toBeDefined() + expect(existsSync(workspace.path)).toBe(true) + + const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId })) + expect(missing.result).toMatchObject({ + ok: false, + error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } }, + }) + + const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace + expect(reregistered.workspaceId).not.toBe(workspace.workspaceId) + expect(reregistered.path).toBe(workspace.path) + expect(reregistered.sessionIds).toEqual([]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + abort.abort() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index a9a5eac9ba..38ad7a52c9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -40,6 +40,7 @@ function scriptedApi(overrides: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + delete: r => ok(r, { deleted: true as const }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), }, commands: { @@ -76,13 +77,15 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) }) - it('routes workspace rename and insertSessionBefore through the wire', async () => { + it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => { const api = scriptedApi() const c = client(api) const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) expect(renamed.result.ok).toBe(true) const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) expect(anchored.result.ok).toBe(true) const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e8d65d2a62..3978b2cfe4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -25,6 +25,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } } }, async history(request) { + if (request.payload.sessionId === ('with-todos' as never)) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { events: [], hasMore: false, todos: [{ content: 'current', status: 'in_progress' as const }] } }, + } + } return { rpcId: request.rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } }, @@ -58,6 +64,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, } }, + async delete(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } + }, async insertSessionBefore(request) { return { rpcId: request.rpcId, @@ -116,6 +125,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.rpcId).toMatch(/[0-9a-f-]{36}/) }) + it('carries the tail-page todos projection through the wire schema (Zod must not strip it)', async () => { + const response = await client().sessions.history({ sessionId: 'with-todos' as never }) + expect(response.result.ok).toBe(true) + if (response.result.ok) expect(response.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }]) + }) + it('carries a business error as 200 + error result', async () => { const response = await client().sessions.history({ sessionId: 'missing' as never }) expect(response.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 02ca8dec22..c1af02b7b9 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -14,6 +14,7 @@ import { import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceDeleteRequestSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, @@ -177,6 +178,13 @@ describe('workspace domain schemas', () => { expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') }) + it('validates workspace deletion payload and receipt', () => { + expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1') + expect(() => workspaceDeleteRequestSchema.parse({})).toThrow() + expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true }) + expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() + }) + it('insertSessionBefore accepts an anchored and an anchorless move', () => { expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() @@ -273,6 +281,11 @@ describe('events frame schemas', () => { { type: 'host/session-removed', sessionId: 's' }, { type: 'host/session-status', sessionId: 's', running: true }, { type: 'host/agent-error', sessionId: 's', message: 'boom' }, + { type: 'host/workspace-changed', workspace: { + workspaceId: 'w', path: '/w', title: 'w', sessionIds: [], + createdAt: '0', updatedAt: '0', + } }, + { type: 'host/workspace-removed', workspaceId: 'w' }, { type: 'host/commands-changed' }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index 9749e012de..0cd2ab4358 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/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 -README.md: 0278a4a582e535125d001e09736b89f13be72a0c -README.zh.md: e3e2b9559d69e4be10cd4d373bbda2dd47396b72 +README.md: 13a04aa9f73fec5824069644449009989d6fd924 +README.zh.md: 3e417c5f8be1f7831b99940c2a4aec815dc2c5b6 diff --git a/packages/llm/README.md b/packages/llm/README.md index 0278a4a582..13a04aa9f7 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -9,7 +9,7 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | | `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | | `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) | -| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | +| `llm-deepseek/` | DeepSeek API adapter (direct fetch + eventsource-parser SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index e3e2b9559d..3e417c5f8b 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -9,7 +9,7 @@ LLM seam 及其提供方适配器。接口包(`llm`)拥有抽象服务、内 | `llm/` | 抽象 LLM 服务 + 内容块词汇 + 分片组装器 | `ctx.llm` | | `token-meter/` | 感知回放的请求与表层 token 测量 | `ctx.tokenMeter` | | `llm-retry/` | 有界的暂时性请求重试策略 | (监听 `agent/request-error`) | -| `llm-deepseek/` | DeepSeek API 适配器(手写 fetch/SSE) | (注册到 `ctx.llm`) | +| `llm-deepseek/` | DeepSeek API 适配器(直接 fetch + eventsource-parser SSE) | (注册到 `ctx.llm`) | | `llm-pi-ai/` | 通过 `@earendil-works/pi-ai` 实现的多提供方适配器 | (注册到 `ctx.llm`) | 接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器可以解析精确的提供方/模型上下文容量;token 计量器仍与模型无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动接口或消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index f5b4d04574..a18c71b400 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -1,6 +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 -README.md: e191f3fcd265a6ca9cec3a8dae5f730ce27accf1 -README.zh.md: 268096e5f1a145e8d5cf6469524d36fe48984617 +# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md +README.md: 4358620295547248ca87c42e07022c5eab0c947b +README.zh.md: 4ecc5dd2e5980751ca6e724b5041efefc8114077 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index e191f3fcd2..4358620295 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol. +DeepSeek chat-completions adapter for the harness LLM seam: direct `fetch` + SSE (framed by `eventsource-parser`) translating the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol. A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design. @@ -17,7 +17,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled - reasoningEffort: high # optional; high | max — omitted ⇒ not sent + reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro @@ -30,11 +30,11 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; 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` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for UI selectors and deployment introspection, 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.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. +`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. -`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default). +The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. -`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults. +`thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. `streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. @@ -45,6 +45,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Wire-format notes (verified live + against the official docs) - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`. +- The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. @@ -55,7 +56,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. ## Model Experience @@ -81,7 +82,7 @@ Reasoning, text, and raw-string tool arguments are translated into harness chunk #### Token effect -Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input. +Generated tokens follow the request's logged reasoning effort and `maxTokens`; only loop-retained blocks affect later input. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 268096e5f1..4ecc5dd2e5 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE,将官方协议格式(真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。 +harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE(由 `eventsource-parser` 分帧),将官方协议格式(真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。 同一 seam 的第二个库支持实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包始终拥有 `deepseek` 提供方路由;在同一上下文中装载 `provider: deepseek` 的 pi-ai profile 会按设计抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -17,7 +17,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled - reasoningEffort: high # optional; high | max — omitted ⇒ not sent + reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro @@ -30,11 +30,11 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE 该插件注册唯一提供方路由 `deepseek`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 128,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 UI selector 与部署自省,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 -`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelContext('deepseek', model)` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时返回 `undefined`,不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 +`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时,`context` 字段缺失但不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 -`reasoningEffort` 默认**省略**:未设置时,不发送 `reasoning_effort` 协议字段,服务器会为模型应用自身默认值。只接受 `high` 和 `max`(DeepSeek 官方 effort 级别)。只有在启用 thinking 时才有意义(提供方默认启用)。 +同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 -`thinking`/`reasoningEffort` 是适配器级请求默认值,序列化为官方顶层 `thinking: {type}`/`reasoning_effort` 协议字段。它们位于适配器配置中(而非 `GenerateOptions`),以保持核心词汇与提供方无关。携带 `GenerateOptions.purpose: 'session-title'` 的请求会强制禁用 thinking 并省略 `reasoning_effort`,将有界输出保留给可见标题文本,不改变会话或压缩默认值。 +`thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩默认值。 `streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;agent 级重试是独立插件策略。 @@ -45,6 +45,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE ## 协议格式说明(已通过实时请求与官方文档验证) - 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish chunk 上,也可能作为尾随仅 usage chunk 到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。 +- 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 跨越协议。 - 第一个 thinking 模式 chunk 携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。 - **Reasoning 回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(thinking 模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。 - Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。 @@ -55,7 +56,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。 ## 模型体验 @@ -81,7 +82,7 @@ Reasoning、文本与原始字符串工具参数会转换为 harness chunk,供 #### Token 影响 -生成 token 遵循提供方 thinking 与 effort 设置及请求的 `maxTokens`;只有 loop 保留的块会影响后续输入。 +生成 token 遵循请求中已记录的推理强度和 `maxTokens`;只有 loop 保留的块会影响后续输入。 #### KV Cache 影响 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 4946c2bcd9..d233ef7764 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -33,6 +33,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "eventsource-parser": "^3.1.0", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 64faa3b725..4cc500e29d 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,12 +5,12 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, - LlmModelContext, LlmModelInfo, LlmProviderInfo, + LlmResolvedModelInfo, StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' @@ -20,7 +20,7 @@ import { parseSse } from './sse.ts' import { translate } from './translate.ts' import type { WireError } from './types.ts' -/** One optional model entry advertised by the hand-written adapter. */ +/** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ id: string @@ -51,6 +51,26 @@ export interface DeepSeekAdapterOptions { /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' +const OFF_REASONING_EFFORT = ReasoningEffortId('off') +const HIGH_REASONING_EFFORT = ReasoningEffortId('high') +const MAX_REASONING_EFFORT = ReasoningEffortId('max') +const REASONING_EFFORTS = [ + { id: OFF_REASONING_EFFORT, name: 'Off' }, + { id: HIGH_REASONING_EFFORT, name: 'High' }, + { id: MAX_REASONING_EFFORT, name: 'Max' }, +] as const +const OFF_ONLY_REASONING_EFFORTS = [ + { id: OFF_REASONING_EFFORT, name: 'Off' }, +] as const + +function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo { + return { + provider, + id: model.id, + name: model.name ?? model.id, + ...model.description === undefined ? {} : { description: model.description }, + } +} function providerRetryAfterMs(value: string | null): number | undefined { if (value === null) return undefined @@ -98,6 +118,11 @@ export class DeepSeekAdapter extends LlmAdapter { constructor(private readonly options: DeepSeekAdapterOptions) { super() + if (options.defaults?.thinking === 'disabled' + && options.defaults.reasoningEffort !== undefined + && options.defaults.reasoningEffort !== 'off') { + throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') + } if (options.defaultContextWindow !== undefined && (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) { throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') @@ -117,21 +142,40 @@ export class DeepSeekAdapter extends LlmAdapter { } override listModels(provider: string): Promise { - return Promise.resolve((this.options.models ?? []).map(model => ({ - provider, - id: model.id, - name: model.name ?? model.id, - ...model.description === undefined ? {} : { description: model.description }, - }))) + return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model))) } - override resolveModelContext( - _provider: string, + override resolveModel( + provider: string, model: string, - ): Promise { - const contextWindow = this.options.models?.find(entry => entry.id === model)?.contextWindow + _signal?: AbortSignal, + ): Promise { + const configured = this.options.models?.find(entry => entry.id === model) + const contextWindow = configured?.contextWindow ?? this.options.defaultContextWindow - return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + return Promise.resolve({ + ...configured === undefined + ? { provider, id: model, name: model } + : modelInfo(provider, configured), + ...contextWindow === undefined ? {} : { context: { contextWindow } }, + ...this.options.defaults?.thinking === 'disabled' + ? { + reasoning: { + efforts: OFF_ONLY_REASONING_EFFORTS, + defaultEffort: OFF_REASONING_EFFORT, + }, + } + : { + reasoning: { + efforts: REASONING_EFFORTS, + defaultEffort: this.options.defaults?.reasoningEffort === 'off' + ? OFF_REASONING_EFFORT + : this.options.defaults?.reasoningEffort === 'max' + ? MAX_REASONING_EFFORT + : HIGH_REASONING_EFFORT, + }, + }, + }) } async * stream(options: GenerateOptions): AsyncIterable { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 66828fc954..b5468f4bdb 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -28,18 +28,19 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ /** * Plugin config, validated by the same-named schemastery schema. Every field * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), and omitted - * thinking fields send nothing on the wire, so the provider default applies. + * missing API key fails plugin load, not the first call), omitted thinking + * mode uses the provider default, and omitted reasoning effort resolves to + * `high`. */ export interface Config { /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ apiKey?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Thinking-mode default for every request (provider default: enabled). */ + /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Thinking effort (only meaningful with thinking enabled). */ - reasoningEffort?: 'high' | 'max' + /** Default thinking effort (default `high`); `off` disables thinking per request. */ + reasoningEffort?: 'off' | 'high' | 'max' /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ @@ -59,7 +60,7 @@ export const Config: z = z.object({ apiKey: z.string(), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), - reasoningEffort: z.union(['high', 'max']), + reasoningEffort: z.union(['off', 'high', 'max']), defaultContextWindow: z.number().step(1).min(1), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), @@ -94,6 +95,11 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee } export function apply(ctx: Context, config: Config): void { + if (config.thinking === 'disabled' + && config.reasoningEffort !== undefined + && config.reasoningEffort !== 'off') { + throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') + } const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY if (apiKey === undefined || apiKey.length === 0) { throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)') diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index bf9515c942..fb6b8d9117 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,13 +6,49 @@ * @module dsh-llm-deepseek/serialize */ +import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' /** Adapter-level request defaults (from plugin config). */ export interface RequestDefaults { thinking?: 'enabled' | 'disabled' | undefined - reasoningEffort?: 'high' | 'max' | undefined + reasoningEffort?: 'off' | 'high' | 'max' | undefined +} + +interface ResolvedThinking { + thinking?: 'enabled' | 'disabled' + reasoningEffort?: 'high' | 'max' +} + +/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */ +function reasoningEffort(effort: NonNullable): 'off' | 'high' | 'max' { + if (effort === 'off' || effort === 'high' || effort === 'max') { + return effort as 'off' | 'high' | 'max' + } + throw new LlmError( + `DeepSeek does not support reasoning effort "${effort}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) +} + +/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */ +function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking { + if (options.purpose === 'session-title') return { thinking: 'disabled' } + const effort = options.reasoningEffort === undefined + ? defaults.reasoningEffort + : reasoningEffort(options.reasoningEffort) + if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') { + throw new LlmError( + `DeepSeek deployment does not support reasoning effort "${effort}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) + } + if (effort === 'off') return { thinking: 'disabled' } + if (effort === 'high' || effort === 'max') { + return { thinking: 'enabled', reasoningEffort: effort } + } + return defaults.thinking === undefined ? {} : { thinking: defaults.thinking } } /** Join the text blocks of a message (used for user/tool-result content). */ @@ -120,16 +156,17 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa })) // A short title budget must produce visible text; conversation and // compaction calls continue to inherit the adapter's thinking defaults. - const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking - const reasoningEffort = options.purpose === 'session-title' ? undefined : defaults.reasoningEffort + const resolvedThinking = resolveThinking(options, defaults) return { model: options.model, messages, stream: true, stream_options: { include_usage: true }, - ...thinking !== undefined ? { thinking: { type: thinking } } : {}, - ...reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {}, + ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, + ...resolvedThinking.reasoningEffort !== undefined + ? { reasoning_effort: resolvedThinking.reasoningEffort } + : {}, ...tools !== undefined && tools.length > 0 ? { tools } : {}, ...options.temperature !== undefined ? { temperature: options.temperature } : {}, ...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}, diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index 17080b10b3..a8807a857c 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,65 +1,33 @@ /** - * Decode an SSE byte stream into event `data` payloads. Network reads may split UTF-8 or lines; - * CRLF, comments, non-data fields, and multi-data events are handled per SSE rules. The literal - * `[DONE]` is yielded so the caller owns final flushing, and EOF before it raises {@link LlmError}. + * Decode an SSE byte stream into event `data` payloads. Framing — chunk + * reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping, + * multi-`data:` joining — is `eventsource-parser`'s; this module keeps only + * the DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns + * final flushing, and EOF before it raises {@link LlmError}. Framing is + * spec-strict: an event dispatches only on its blank-line terminator, so an + * unterminated tail at EOF is truncation, not a flushable payload. * - * Minimal SSE (text/event-stream) parser for the chat-completions stream. * @module dsh-llm-deepseek/sse */ +import { EventSourceParserStream } from 'eventsource-parser/stream' import { LlmError } from '@deepseek-ai/dsh-llm' /** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */ export const DONE = '[DONE]' -/** Extract the joined data payload from one raw SSE event block. */ -function eventData(block: string): string | undefined { - const data: string[] = [] - for (const rawLine of block.split('\n')) { - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine - if (line.startsWith('data:')) { - // The spec strips ONE leading space after the colon. - data.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5)) - } - // Comments (':…') and other fields (event:, id:, retry:) are ignored. - } - if (data.length === 0) return undefined - return data.join('\n') -} - /** - * Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final + * Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends * without it (truncated response — the model call cannot be trusted). * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. */ -export async function* parseSse(stream: AsyncIterable): AsyncGenerator { - const decoder = new TextDecoder() - let buffer = '' - - for await (const bytes of stream) { - buffer += decoder.decode(bytes, { stream: true }) - // Events are separated by a blank line (\n\n; tolerate \r\n\r\n via the - // per-line \r strip in eventData and a normalized split here). - let boundary: number - while ((boundary = buffer.search(/\r?\n\r?\n/)) !== -1) { - const matched = /\r?\n\r?\n/.exec(buffer.slice(boundary)) - const block = buffer.slice(0, boundary) - // matched cannot be null: search() just found the same pattern at 0. - buffer = buffer.slice(boundary + (matched as RegExpExecArray)[0].length) - const data = eventData(block) - if (data === undefined) continue - yield data - if (data === DONE) return - } - } - - // Flush any final un-terminated event (servers usually end with \n\n, but - // a trailing block without one is still parseable). - buffer += decoder.decode() - const data = eventData(buffer) - if (data !== undefined) { +export async function* parseSse(stream: ReadableStream): AsyncGenerator { + const events = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + for await (const { data } of events) { yield data if (data === DONE) return } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index e02476eaef..81fc365c05 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' /** - * Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across + * Real-API e2e for the direct-fetch adapter: V4 Flash + V4 Pro across * thinking modes and both official effort levels. Key-gated — skips * entirely without $DEEPSEEK_API_KEY (see vitest.e2e.config.ts). */ @@ -50,41 +50,40 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { - it('flash + thinking disabled: plain text generation', async () => { - const ctx = await harness(FLASH, { thinking: 'disabled' }) - const result = await assemble(ctx,{ + it('flash dynamically switches from off to high', async () => { + const ctx = await harness(FLASH, { reasoningEffort: 'off' }) + const withoutThinking = await assemble(ctx,{ model: FLASH, messages: ask('Reply with exactly the word: pong'), maxTokens: 50, }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('pong') - expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false) - expect(result.usage?.inputTokens).toBeGreaterThan(0) - expect(result.usage?.outputTokens).toBeGreaterThan(0) - }) + expect(withoutThinking.finish.kind).toBe('stop') + expect(textOf(withoutThinking).toLowerCase()).toContain('pong') + expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false) + expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0) + expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0) - it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => { - const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' }) - const result = await assemble(ctx,{ + const withThinking = await assemble(ctx,{ model: FLASH, + reasoningEffort: ReasoningEffortId('high'), messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), maxTokens: 2000, }) - expect(result.finish.kind).toBe('stop') - expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true) - expect(textOf(result)).toContain('9.8') - expect(result.usage?.reasoningTokens).toBeGreaterThan(0) + expect(withThinking.finish.kind).toBe('stop') + expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true) + expect(textOf(withThinking)).toContain('9.8') + expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) }) it.each(['high', 'max'] as const)( 'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback', async (effort) => { - const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort }) + const ctx = await harness(PRO, { thinking: 'enabled' }) // Turn 1: the model must call the tool (and think before it). const first = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId(effort), messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], maxTokens: 2000, @@ -99,6 +98,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () // block in history (the official thinking+tools passback rule). const second = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId(effort), messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), { role: 'assistant', content: first.message.content }, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 145017ea3d..341d563b1b 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -8,6 +8,7 @@ import LlmService, { LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, + ReasoningEffortId, userAgent, } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -120,6 +121,7 @@ describe('DeepSeekAdapter against a mock server', () => { // The wire request carried the auth header contents we configured. expect(server.requests[0]).toMatchObject({ model: 'deepseek-v4-flash', + reasoning_effort: 'high', stream: true, stream_options: { include_usage: true }, }) @@ -173,9 +175,45 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') }) - it('forwards thinking config onto the wire', async () => { + it('switches dynamically from the configured high default through off to max', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'high' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('off'), + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('max'), + messages: [{ role: 'user', content: [{ type: 'text', text: 'one more time' }] }], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'enabled' }, + reasoning_effort: 'high', + }) + expect(server.requests[1]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[1]).not.toHaveProperty('reasoning_effort') + expect(server.requests[2]).toMatchObject({ + thinking: { type: 'enabled' }, + reasoning_effort: 'max', + }) + }) + + it('publishes only off and omits the wire effort when thinking is disabled', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' }) + const ctx = await harness(server.url, { thinking: 'disabled' }) await assemble(ctx,{ model: 'deepseek-v4-flash', @@ -183,10 +221,52 @@ describe('DeepSeekAdapter against a mock server', () => { }) expect(server.requests[0]).toMatchObject({ thinking: { type: 'disabled' }, - reasoning_effort: 'high', }) + expect(server.requests[0]).not.toHaveProperty('reasoning_effort') + await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + defaultEffort: ReasoningEffortId('off'), + }, + }) }) + it('rejects a per-request effort before I/O when thinking is disabled', async () => { + const server = await mockServer([]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + await expect(assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(0) + }) + + it.each(['high', 'max'])( + 'rejects direct adapter effort %s before I/O when thinking is disabled', + async (effort) => { + const server = await mockServer([]) + const adapter = new DeepSeekAdapter({ + apiKey: 'test-key', + baseURL: server.url, + defaults: { thinking: 'disabled' }, + }) + + const stream = adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId(effort), + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }) + await expect(async () => { + for await (const _chunk of stream) { /* drain */ } + }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(0) + }, + ) + it.each([ [401, 'AUTH'], [403, 'AUTH'], @@ -531,8 +611,100 @@ describe('plugin registration and config', () => { { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, ]) - await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash')) - .resolves.toEqual({ contextWindow: 128_000 }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + .resolves.toMatchObject({ + provider: 'deepseek', + id: 'deepseek-v4-flash', + name: 'deepseek-v4-flash', + context: { contextWindow: 128_000 }, + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + }) + + it.each(['off', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + reasoningEffort: effort, + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId(effort), + }, + }) + }) + + it('accepts off as the default when thinking is deployment-disabled', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort: 'off', + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it.each(['high', 'max'] as const)( + 'rejects configured reasoning effort %s when thinking is disabled', + async (reasoningEffort) => { + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort, + })).rejects.toThrow(/only reasoningEffort "off"/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each(['high', 'max'] as const)( + 'rejects disabled-thinking effort %s at the direct constructor boundary', + (reasoningEffort) => { + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + defaults: { thinking: 'disabled', reasoningEffort }, + })).toThrow(/only reasoningEffort "off"/) + }, + ) + + it('accepts disabled thinking with off at the direct constructor boundary', async () => { + const adapter = new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + defaults: { thinking: 'disabled', reasoningEffort: 'off' }, + }) + await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({ + reasoning: { + efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + defaultEffort: ReasoningEffortId('off'), + }, + }) }) it('uses the default model catalog when apply is called directly', async () => { @@ -565,10 +737,15 @@ describe('plugin registration and config', () => { { provider: 'deepseek', id: 'private-fast', name: 'private-fast' }, { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, ]) - await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast')) - .resolves.toEqual({ contextWindow: 32_000 }) - await expect(ctx.llm.resolveModelContext('deepseek', 'arbitrary-unlisted')) - .resolves.toBeUndefined() + await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast')) + .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'private-reasoner')) + .resolves.toMatchObject({ + name: 'Private Reasoner', + description: 'Higher reasoning budget', + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'arbitrary-unlisted')) + .resolves.not.toHaveProperty('context') }) it('uses exact model capacity before the adapter-wide default', async () => { @@ -584,12 +761,12 @@ describe('plugin registration and config', () => { ], }) - await expect(ctx.llm.resolveModelContext('deepseek', 'inherits-default')) - .resolves.toEqual({ contextWindow: 256_000 }) - await expect(ctx.llm.resolveModelContext('deepseek', 'exact-override')) - .resolves.toEqual({ contextWindow: 64_000 }) - await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted-pass-through')) - .resolves.toEqual({ contextWindow: 256_000 }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'inherits-default')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'exact-override')) + .resolves.toMatchObject({ context: { contextWindow: 64_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) }) it('allows an explicit empty model catalog', async () => { diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 566c71b7f2..213e910c3f 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm' +import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '../src/serialize.ts' @@ -174,15 +174,47 @@ describe('serializeRequest', () => { expect(wire.tools).toBeUndefined() }) - it('applies adapter defaults for thinking and effort', () => { - const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' }) + it('maps adapter-default thinking and the request reasoning effort', () => { + const wire = serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('max') }), + { thinking: 'enabled', reasoningEffort: 'high' }, + ) expect(wire.thinking).toEqual({ type: 'enabled' }) expect(wire.reasoning_effort).toBe('max') }) + it('maps off to disabled thinking without a wire reasoning effort', () => { + const wire = serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('off') }), + { thinking: 'enabled', reasoningEffort: 'max' }, + ) + expect(wire.thinking).toEqual({ type: 'disabled' }) + expect(wire.reasoning_effort).toBeUndefined() + }) + + it('re-enables thinking when max overrides an off default', () => { + const wire = serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('max') }), + { reasoningEffort: 'off' }, + ) + expect(wire.thinking).toEqual({ type: 'enabled' }) + expect(wire.reasoning_effort).toBe('max') + }) + + it('rejects enabling thinking when the deployment is locked to disabled', () => { + expect(() => serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('high') }), + { thinking: 'disabled' }, + )).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) + }) + it('disables thinking for session-title requests without changing adapter defaults', () => { const wire = serializeRequest( - request({ messages: history, purpose: 'session-title' }), + request({ + messages: history, + purpose: 'session-title', + reasoningEffort: ReasoningEffortId('max'), + }), { thinking: 'enabled', reasoningEffort: 'max' }, ) expect(wire.thinking).toEqual({ type: 'disabled' }) @@ -194,6 +226,19 @@ describe('serializeRequest', () => { expect(wire.thinking).toBeUndefined() expect(wire.reasoning_effort).toBeUndefined() }) + + it('preserves an explicit enabled default without inventing a wire effort', () => { + const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' }) + expect(wire.thinking).toEqual({ type: 'enabled' }) + expect(wire.reasoning_effort).toBeUndefined() + }) + + it('rejects an effort outside the DeepSeek capability', () => { + expect(() => serializeRequest(request({ + messages: history, + reasoningEffort: ReasoningEffortId('medium'), + }))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) + }) }) describe('review fixes: assistant content shapes', () => { diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index b18862e4f3..7ebb494a4d 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -2,12 +2,21 @@ import { describe, expect, it } from 'vitest' import { LlmError } from '@deepseek-ai/dsh-llm' import { DONE, parseSse } from '../src/sse.ts' -/** Build a byte stream from string fragments (fragments = network reads). */ -async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator { +/** + * DeepSeek protocol contract only: the [DONE] sentinel and STREAM_CLOSED on + * EOF without it. SSE framing (chunk splits, CRLF, multi-data joins, comments) + * is eventsource-parser's contract, not re-proven here. + */ + +/** Build an SSE byte stream from string fragments (fragments = network reads). */ +function bytes(...fragments: string[]): ReadableStream> { const encoder = new TextEncoder() - for (const fragment of fragments) { - yield typeof fragment === 'string' ? encoder.encode(fragment) : fragment - } + return new ReadableStream({ + start(controller) { + for (const fragment of fragments) controller.enqueue(encoder.encode(fragment)) + controller.close() + }, + }) } async function collect(stream: AsyncIterable): Promise { @@ -17,57 +26,14 @@ async function collect(stream: AsyncIterable): Promise { } describe('parseSse', () => { - it('parses simple events and the DONE sentinel', async () => { + it('yields event payloads and the DONE sentinel', async () => { const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n'))) expect(events).toEqual(['{"a":1}', DONE]) }) - it('handles events split across reads at arbitrary positions', async () => { - const events = await collect(parseSse(bytes('da', 'ta: {"a"', ':1}\n', '\ndata: [DO', 'NE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('handles multi-byte UTF-8 split across reads', async () => { - const encoded = new TextEncoder().encode('data: {"text":"日本語"}\n\ndata: [DONE]\n\n') - // Split inside the 3-byte sequence for 日. - const splitAt = 16 - const events = await collect(parseSse(bytes(encoded.slice(0, splitAt), encoded.slice(splitAt)))) - expect(events).toEqual(['{"text":"日本語"}', DONE]) - }) - - it('tolerates CRLF line endings', async () => { - const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata: [DONE]\r\n\r\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('joins multi-data events with newlines (SSE spec)', async () => { - const events = await collect(parseSse(bytes('data: line1\ndata: line2\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['line1\nline2', DONE]) - }) - - it('ignores comments and non-data fields', async () => { - const events = await collect(parseSse(bytes(': keepalive\nevent: chunk\nid: 7\ndata: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('skips blocks without data fields', async () => { - const events = await collect(parseSse(bytes(': ping\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('preserves data lines without the optional space', async () => { - const events = await collect(parseSse(bytes('data:{"a":1}\n\ndata:[DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('parses several events from one read', async () => { - const events = await collect(parseSse(bytes('data: 1\n\ndata: 2\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['1', '2', DONE]) - }) - - it('flushes a final un-terminated DONE at stream end', async () => { - const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]'))) - expect(events).toEqual(['{"a":1}', DONE]) + it('stops yielding after DONE even when more data follows', async () => { + const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) + expect(events).toEqual([DONE]) }) it('throws STREAM_CLOSED when the stream ends without DONE', async () => { @@ -83,26 +49,10 @@ describe('parseSse', () => { await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/) }) - it('stops yielding after DONE even when more data follows', async () => { - const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) - expect(events).toEqual([DONE]) - }) -}) - -describe('parseSse edge branches', () => { - it('handles a lone CR-terminated data line', async () => { - // Exercises the \r-strip branch on a line that is ONLY "data:…\r". - const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata:[DONE]\r\n\r\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('strips CR from non-data field lines too', async () => { - const events = await collect(parseSse(bytes('event: chunk\r\ndata: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('treats bare "data:" lines as empty payload entries', async () => { - const events = await collect(parseSse(bytes('data:\ndata: x\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['\nx', DONE]) + it('treats a final DONE missing its blank-line terminator as truncation', async () => { + // Spec-strict framing: an event dispatches only on its blank-line + // terminator, so an unterminated tail at EOF is STREAM_CLOSED — real + // providers always terminate events, so a missing terminator is truncation. + await expect(collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))).rejects.toThrow(/without \[DONE\]/) }) }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 4b3e7ad4f0..188f561c04 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -1,6 +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 -README.md: 8a5c955edf9418352a6916e17766b3c06b62c9ba -README.zh.md: 2b953e2aa30da29b7aa307abe3f92c14fb0906d6 +# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md +README.md: 24a4762342f1ce8e71d1a5b1733fe02257823cb8 +README.zh.md: 557dc892c2eac10edc4e2fe4a0a142024942b1a9 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8a5c955edf..24a4762342 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -30,7 +30,9 @@ Configure credentials and deployment-specific transport settings per provider. O Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. -The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin. +The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. + +The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. 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 any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. 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. Harness app attribution wins a conflicting configured header name. @@ -49,6 +51,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state - pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output. - pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. A terminal `stop` whose message carries no content blocks maps to a `finish {kind:'error'}` with code `EMPTY_RESPONSE` (retried by default policy) instead of a successful empty message. - pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map. +- pi-ai's `off` thinking level crosses the Harness capability seam unchanged and becomes an omitted pi-ai common `reasoning` option at dispatch. - `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers. ## App attribution diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 2b953e2aa3..557dc892c2 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -30,7 +30,9 @@ 每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 -适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelContext(provider, model)` 执行相同的精确 descriptor 查找并返回其上下文窗口,让容量元数据保留在拥有路由的适配器上,而非消费插件上。 +适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 + +`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型协议拼写仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs` 和 `streamIdleTimeoutMs`。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 @@ -49,6 +51,7 @@ - pi-ai 工具调用参数是已解析对象;harness 存储原始 JSON 字符串。适配器会解析输入,并将输出重新字符串化。 - pi-ai 将失败报告为流内错误事件;它们会映射到 `finish {kind:'error'|'aborted', failure}` chunk。提供方特定错误文本会区分终端 `QUOTA` 与短暂 `RATE_LIMIT`,针对已解析模型上下文窗口评估的文本与 usage 信号则将溢出规范化为 `CONTEXT_WINDOW_EXCEEDED`。携带零个内容块消息的终止 `stop` 会映射为 `finish {kind:'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试),而非成功空消息。 - pi-ai 将 reasoning token 折叠到输出 usage 中;没有可映射的独立 reasoning 计数。 +- pi-ai 的 `off` thinking 级别会原样穿过 Harness 能力 seam,并在分派时变为被省略的 pi-ai 通用 `reasoning` 选项。 - `GenerateOptions.stop` 会以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式输出表层无法保证所有提供方都支持它。 ## 应用归因 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index ed0fb9fae4..5fb980d259 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -7,13 +7,27 @@ import { streamSimple } from '@earendil-works/pi-ai/compat' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' +import { getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, Model, + ModelThinkingLevel, SimpleStreamOptions, + ThinkingLevel, } from '@earendil-works/pi-ai' -import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { + attributionHeaders, + LlmAdapter, + LlmError, + ReasoningEffortId, +} from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + ReasoningEffortId as ReasoningEffortIdType, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' @@ -30,7 +44,7 @@ export interface PiAiAdapterOptions { * Resolve a catalog model dynamically and apply only the configured endpoint * override, preserving the catalog's API/capability/compatibility metadata. */ -function resolveModel(profile: PiAiProviderProfile, modelId: string): Model { +function resolvePiModel(profile: PiAiProviderProfile, modelId: string): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined if (model === undefined) { throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') @@ -39,10 +53,14 @@ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model } /** Copy profile stream knobs into pi-ai's common option vocabulary. */ -function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { +function profileOptions( + profile: PiAiProviderProfile, + reasoning: ModelThinkingLevel | undefined, +): SimpleStreamOptions { + const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning return { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, - ...profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }, + ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, ...profile.transport === undefined ? {} : { transport: profile.transport }, @@ -53,6 +71,20 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { } } +/** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */ +function resolveReasoningLevel( + model: Model, + effort: ReasoningEffortIdType | ModelThinkingLevel | undefined, +): ModelThinkingLevel | undefined { + if (effort === undefined) return undefined + const supported = getSupportedThinkingLevels(model) + if (supported.some(level => level === effort)) return effort as ModelThinkingLevel + throw new LlmError( + `pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) +} + /** Merge deployment headers while removing case-insensitive attribution collisions. */ function requestHeaders(headers: Readonly> | undefined): Record { const attribution = attributionHeaders() @@ -87,10 +119,11 @@ export class PiAiAdapter extends LlmAdapter { }))) } - override resolveModelContext( + override resolveModel( provider: string, model: string, - ): Promise { + _signal?: AbortSignal, + ): Promise { const profile = this.profiles.get(provider) if (profile === undefined) { return Promise.reject(new LlmError( @@ -98,9 +131,26 @@ export class PiAiAdapter extends LlmAdapter { 'NO_ADAPTER', )) } - return Promise.resolve().then(() => ({ - contextWindow: resolveModel(profile, model).contextWindow, - })) + return Promise.resolve().then(() => { + const resolvedModel = resolvePiModel(profile, model) + const levels = getSupportedThinkingLevels(resolvedModel) + const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) + return { + provider, + id: model, + name: resolvedModel.name, + context: { contextWindow: resolvedModel.contextWindow }, + reasoning: { + efforts: levels.map(level => ({ + id: ReasoningEffortId(level), + name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`, + })), + ...defaultLevel === undefined + ? {} + : { defaultEffort: ReasoningEffortId(defaultLevel) }, + }, + } + }) } async * stream(options: GenerateOptions): AsyncIterable { @@ -111,7 +161,11 @@ export class PiAiAdapter extends LlmAdapter { if (profile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } - const model = resolveModel(profile, options.model) + const model = resolvePiModel(profile, options.model) + const reasoning = resolveReasoningLevel( + model, + options.reasoningEffort ?? profile.reasoning, + ) const consumer = new AbortController() const upstream = options.signal === undefined @@ -122,7 +176,7 @@ export class PiAiAdapter extends LlmAdapter { try { const events = streamSimple(model, toPiContext(options), { - ...profileOptions(profile), + ...profileOptions(profile, reasoning), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 199463aaf6..bf9612d9d4 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -5,7 +5,7 @@ */ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' -import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai' +import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -23,7 +23,7 @@ export interface PiAiProviderProfile { /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ - reasoning?: ThinkingLevel + reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets /** Prompt-cache retention preference. */ @@ -62,7 +62,7 @@ const profile = z.object({ apiKey: z.string(), baseURL: z.string(), headers: z.dict(z.string()), - reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']), + reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), thinkingBudgets, cacheRetention: z.union(['none', 'short', 'long']), transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 77d2cc81dd..be1e89de16 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai' @@ -9,7 +9,7 @@ import { assemble, type AssembledResult } from './assemble.ts' /** * Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro with provider - * defaults and representative high/xhigh reasoning. Mirrors the native + * defaults and representative off/high/max reasoning. Mirrors the native * adapter's StreamChunk contract and exercises a replayed tool follow-up. * Key-gated. */ @@ -74,10 +74,24 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => expect(textOf(result).toLowerCase()).toContain('pong') }) + it('flash + reasoning off: plain text without reasoning blocks', async () => { + const ctx = await harness(FLASH) + const result = await assemble(ctx,{ + model: FLASH, + reasoningEffort: ReasoningEffortId('off'), + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false) + expect(textOf(result).toLowerCase()).toContain('pong') + }) + it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => { - const ctx = await harness(model, { reasoning: 'high' }) + const ctx = await harness(model) const result = await assemble(ctx,{ model, + reasoningEffort: ReasoningEffortId('high'), messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), maxTokens: 2000, }) @@ -86,11 +100,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => expect(textOf(result)).toContain('9.8') }) - it('pro + reasoning xhigh (wire max): tool-call round trip', async () => { - const ctx = await harness(PRO, { reasoning: 'xhigh' }) + it('pro + reasoning max: tool-call round trip', async () => { + const ctx = await harness(PRO) const first = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId('max'), messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], maxTokens: 2000, @@ -103,6 +118,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => const second = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId('max'), messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), first.message, diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index f37b07f624..cb5e60c343 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -124,7 +124,7 @@ describe('PiAiAdapter provider routing', () => { it('forwards common stream options and profile reasoning', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { - reasoning: 'xhigh', + reasoning: 'max', cacheRetention: 'none', transport: 'sse', timeoutMs: 5000, @@ -148,6 +148,33 @@ describe('PiAiAdapter provider routing', () => { }) }) + it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => { + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = await harness(server.url, { reasoning: 'max' }) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'high' }) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('off'), + messages: [], + }) + expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } }) + expect(server.requests[1]).not.toHaveProperty('reasoning_effort') + + await expect(assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('xhigh'), + messages: [], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(2) + }) + it('preserves omitted profile options when constructing the adapter directly', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = new Context() @@ -322,9 +349,69 @@ describe('provider profile lifecycle', () => { provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', }) expect(models.every(model => model.provider === 'openai')).toBe(true) - const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1') - expect(context).toBeDefined() - expect(typeof context?.contextWindow).toBe('number') + const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1') + expect(typeof info.context?.contextWindow).toBe('number') + }) + + it('exposes pi-ai model thinking levels verbatim without inventing a provider default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ provider: 'deepseek' }, { provider: 'openai' }], + }) + + await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + .resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + }, + }) + const extended = await ctx.llm.resolveModelInfo('openai', 'gpt-5.6-sol') + expect(extended.reasoning?.efforts.map(effort => effort.id)).toEqual([ + ReasoningEffortId('off'), + ReasoningEffortId('minimal'), + ReasoningEffortId('low'), + ReasoningEffortId('medium'), + ReasoningEffortId('high'), + ReasoningEffortId('xhigh'), + ReasoningEffortId('max'), + ]) + await expect(ctx.llm.resolveModelInfo('openai', 'gpt-4.1')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + }, + }) + }) + + it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => { + const supported = new Context() + await supported.plugin(LlmService) + await supported.plugin(LlmPiAi, { + providers: [{ provider: 'deepseek', reasoning: 'max' }], + }) + await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) + + const unsupported = new Context() + await unsupported.plugin(LlmService) + await unsupported.plugin(LlmPiAi, { + providers: [{ provider: 'deepseek', reasoning: 'medium' }], + }) + await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + + const disabled = new Context() + await disabled.plugin(LlmService) + await disabled.plugin(LlmPiAi, { + providers: [{ provider: 'deepseek', reasoning: 'off' }], + }) + await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) + .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) }) it('accepts absent credentials for pi-ai ambient authentication', async () => { @@ -376,9 +463,9 @@ describe('provider profile lifecycle', () => { it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) - await expect(adapter.resolveModelContext('anthropic', 'claude-sonnet-4')) + await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4')) .rejects.toMatchObject({ code: 'NO_ADAPTER' }) - await expect(adapter.resolveModelContext('openai', 'not-a-catalog-model')) + await expect(adapter.resolveModel('openai', 'not-a-catalog-model')) .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) await expect((async () => { for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ } diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 83f58dd989..085ade62b1 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -1,6 +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 -README.md: 981f7d58802d2ff18633b09b5a4ec8a7b1bf3383 -README.zh.md: 0a6535f41adb8ec90d02dbb56aec257853a19083 +# pnpm run verify-translation-pairing --write packages/llm/llm/README.md +README.md: 3efb3ece3caadeaceaa3c504ba4b10ddb951127a +README.zh.md: 4af8b8d08cc96ff0e36b10b15e1d86afd43004c9 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 981f7d5880..3efb3ece3c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -13,14 +13,18 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. -- `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. +- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. +- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize an adapter-configured default without clamping. +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. -Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`. +Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`. + +Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. ### Events @@ -30,7 +34,7 @@ Context capacity is a separate correctness query, not a catalog decoration or gl ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) @@ -41,7 +45,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Call configuration (`call-config.ts`) -`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. +`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates and defaults it under the turn signal, and the loop logs the effective value before using the prepared call's registration-bound stream. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. ### App attribution (`attribution.ts`) @@ -60,11 +64,11 @@ Every product adapter sends application identity on provider HTTP requests. `att ### Real adapters -Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. +Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. ## Model Experience -None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message. +None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort. #### KV Cache effect diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 0a6535f41a..4af8b8d08c 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -13,14 +13,18 @@ - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber dispose。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 -- `ctx.llm.resolveModelContext(provider: string, model: string): Promise` 从拥有精确路由的适配器解析权威上下文容量。 +- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。 +- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。 +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。 `LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。 提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,适配器则可以接受 `listModels()` 中不存在的模型 id;消费方禁止因模型未列出而拒绝请求。返回的元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 -上下文容量是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelContext()` 会询问拥有精确提供方/模型路由的适配器;适配器可以描述未列出的动态模型,`undefined` 只表示容量不可用。无效的返回容量以 `INVALID_MODEL_CONTEXT` 失败。 +确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 + +推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 ### 事件 @@ -30,7 +34,7 @@ ### 扩展点 -- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据,在已知精确容量时覆盖 `resolveModelContext()`;默认实现将路由 id 用作名称,不公布模型,也不返回容量。 +- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。 - 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出 chunk 后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`。 ### 内容块词汇(`types.ts`) @@ -41,7 +45,7 @@ ### 调用配置(`call-config.ts`) -`LlmCallConfig` 是一个会话请求的提供方 + 模型 + 采样标量(`provider`、`model`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,loop 则记录真实变更。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 +`LlmCallConfig` 是一个会话请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验并填入默认值,loop 随后记录生效值,再使用准备完成调用的注册绑定流。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 ### 应用归因(`attribution.ts`) @@ -60,11 +64,11 @@ ### 真实适配器 -两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用手写 fetch/SSE,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。 +两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。 ## 模型体验 -无。该适配器注册表转发已组装的请求,不添加或更改任何模型边界文本、schema 或消息。 +无。服务不添加或更改任何模型边界文本、schema 或消息;它只会填入并记录适配器配置的推理强度。 #### KV Cache 影响 diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index 259dc49bce..0c0190a325 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -38,3 +38,15 @@ export type ProviderRequestId = Branded<'ProviderRequestId'> export function ProviderRequestId(id: string): ProviderRequestId { return id as ProviderRequestId } + +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +export type ReasoningEffortId = Branded<'ReasoningEffortId'> + +/** + * Brand an adapter-owned reasoning-effort identifier. + * @param id - the opaque identifier exposed by one model capability. + * @returns the same string, branded; no validation is performed. + */ +export function ReasoningEffortId(id: string): ReasoningEffortId { + return id as ReasoningEffortId +} diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index fd6ecf9df4..c5247af143 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -1,24 +1,27 @@ /** * Conversation call configuration and freeze utilities. Provider routing, - * model, and sampling values are request-header state that can affect cache - * reuse; request waterfalls replace them and the loop logs changed snapshots - * instead of allowing silent per-call drift. + * model, reasoning effort, and sampling values are request-header state that + * can affect cache reuse; request waterfalls replace them and the loop logs + * changed snapshots instead of allowing silent per-call drift. * @module dsh-llm/call-config */ import type { GenerateOptions } from './types.ts' +import type { ReasoningEffortId } from './brand.ts' /** Process-local identities of request objects assembled by dsh-agent-loop. */ const AGENT_LOOP_REQUESTS = new WeakSet() /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ export interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] @@ -33,7 +36,13 @@ export interface LlmCallConfig { * @returns whether every field (including the `stop` list, element-wise) matches. */ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean { - if (a.provider !== b.provider || a.model !== b.model || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false + if ( + a.provider !== b.provider + || a.model !== b.model + || a.reasoningEffort !== b.reasoningEffort + || a.temperature !== b.temperature + || a.maxTokens !== b.maxTokens + ) return false if (a.stop === undefined || b.stop === undefined) return a.stop === b.stop return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i]) } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 6765833e8c..05c4ee87ac 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -10,14 +10,15 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, LlmFailure, - LlmModelContext, LlmModelInfo, + LlmResolvedModelInfo, LlmProviderInfo, Message, StreamChunk, } from './types.ts' import type { ProviderRequestId } from './brand.ts' -import { deepFreeze } from './call-config.ts' +import { callConfigEquals, deepFreeze } from './call-config.ts' +import type { LlmCallConfig } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' import type { AdapterFailureScope } from './adapter-failure.ts' @@ -103,11 +104,25 @@ export class LlmError extends HarnessError { } } +/** One model call whose config and adapter registration were resolved together. */ +export interface PreparedLlmCall { + /** Detached, deep-frozen config with any adapter-owned default materialized. */ + readonly config: LlmCallConfig + /** + * Dispatch this call once through the registration captured during + * preparation. The request's call-config fields must match {@link config}; + * reuse or mismatch fails with `INVALID_PREPARED_CALL`. + * @param options - fully assembled request carrying the prepared config. + * @returns the chunk stream, including the `llm/stream` waterfall. + */ + stream(options: GenerateOptions): AsyncIterable +} + /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ export abstract class LlmAdapter { /** @@ -131,17 +146,20 @@ export abstract class LlmAdapter { } /** - * Resolve context capacity for one model accepted by this adapter. Absence - * means the adapter does not know the capacity, not that routing is invalid. - * @param _provider - one provider route owned by this adapter. - * @param _model - exact model id passed to {@link GenerateOptions.model}. - * @returns provider-owned context metadata, or `undefined` when unavailable. + * Resolve all metadata available for one exact model. This query is + * independent of the advisory catalog and does not validate request routing. + * @param provider - one provider route owned by this adapter. + * @param model - exact model id passed to {@link GenerateOptions.model}. + * @param _signal - cancellation for this exact-model lookup; asynchronous + * implementations must settle promptly after it aborts. + * @returns provider/model identity plus any context and reasoning metadata. */ - resolveModelContext( - _provider: string, - _model: string, - ): Promise { - return Promise.resolve(undefined) + resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise { + return Promise.resolve({ provider, id: model, name: model }) } /** @@ -157,7 +175,7 @@ export abstract class LlmAdapter { * surface, interceptable via the `llm/stream` waterfall. */ export class LlmService extends Service { - private adapters = new Map() + private adapters = new Map() constructor(ctx: Context) { super(ctx, 'llm') @@ -175,7 +193,7 @@ export class LlmService extends Service { const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') const unique = new Set() - const registrations: { adapter: LlmAdapter; provider: LlmProviderInfo }[] = [] + const registrations: AdapterRegistration[] = [] for (const provider of providers) { if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') if (unique.has(provider) || this.adapters.has(provider)) { @@ -240,29 +258,170 @@ export class LlmService extends Service { } /** - * Resolve context capacity from the adapter that owns one exact route. - * This query is independent of the advisory model catalog: an unlisted model - * may return metadata, while `undefined` never rejects later routing. + * Resolve and validate all metadata from the adapter that owns one exact + * route. The result is detached from adapter-owned objects; catalog + * membership remains advisory and does not control request routing. * @param provider - registered provider route to inspect. * @param model - exact model id passed to the adapter. - * @returns detached context metadata, or `undefined` when the adapter has none. + * @param signal - optional cancellation for adapter-owned asynchronous lookup. + * @returns exact model identity plus available context and reasoning metadata. */ - async resolveModelContext( + async resolveModelInfo( provider: string, model: string, - ): Promise { - const context = await this.registration(provider).adapter.resolveModelContext(provider, model) - if (context === undefined) return undefined - if (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0) { + signal?: AbortSignal, + ): Promise { + return this.resolveModelInfoFor(this.registration(provider), model, signal) + } + + private async resolveModelInfoFor( + registration: AdapterRegistration, + model: string, + signal?: AbortSignal, + ): Promise { + const provider = registration.provider.id + const resolved = await registration.adapter.resolveModel(provider, model, signal) + if ( + typeof resolved.provider !== 'string' + || resolved.provider !== provider + || typeof resolved.id !== 'string' + || resolved.id !== model + || typeof resolved.name !== 'string' + || resolved.name.length === 0 + || (resolved.description !== undefined && typeof resolved.description !== 'string') + ) { + throw new LlmError( + `adapter returned invalid exact model metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_INFO', + ) + } + const context = resolved.context + if (context !== undefined && (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0)) { throw new LlmError( `adapter returned invalid context metadata for provider "${provider}" model "${model}"`, 'INVALID_MODEL_CONTEXT', ) } - return { contextWindow: context.contextWindow } + const info: LlmResolvedModelInfo = { + provider, + id: model, + name: resolved.name, + ...resolved.description === undefined ? {} : { description: resolved.description }, + ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, + } + const reasoning = resolved.reasoning + if (reasoning === undefined) return info + if (reasoning.efforts.length === 0) { + throw new LlmError( + `adapter returned invalid reasoning metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_REASONING', + ) + } + const seen = new Set() + const efforts = reasoning.efforts.map((effort) => { + if ( + typeof effort.id !== 'string' + || effort.id.length === 0 + || typeof effort.name !== 'string' + || effort.name.length === 0 + || (effort.description !== undefined && typeof effort.description !== 'string') + || seen.has(effort.id) + ) { + throw new LlmError( + `adapter returned invalid or duplicate reasoning effort metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_REASONING', + ) + } + seen.add(effort.id) + return { + id: effort.id, + name: effort.name, + ...effort.description === undefined ? {} : { description: effort.description }, + } + }) + if (reasoning.defaultEffort !== undefined && !seen.has(reasoning.defaultEffort)) { + throw new LlmError( + `adapter returned an unknown default reasoning effort for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_REASONING', + ) + } + return { + ...info, + reasoning: { + efforts, + ...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort }, + }, + } } - private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } { + /** + * Validate a conversation call config against its exact model capability and + * materialize an adapter-configured default. Unsupported explicit efforts + * reject before provider I/O; no clamping or aliasing is performed. This + * standalone query does not bind a later dispatch; use {@link prepareCall} + * when logging and streaming must share one adapter registration. + * @param config - provider/model route and optional request controls. + * @param signal - optional cancellation for adapter-owned capability lookup. + * @returns a detached config only when a default must be materialized. + */ + async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise { + return this.resolveCallConfigFor(this.registration(config.provider), config, signal) + } + + private async resolveCallConfigFor( + registration: AdapterRegistration, + config: LlmCallConfig, + signal?: AbortSignal, + ): Promise { + const reasoning = (await this.resolveModelInfoFor(registration, config.model, signal)).reasoning + const requested = config.reasoningEffort + if (reasoning === undefined) { + if (requested !== undefined) { + throw new LlmError( + `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${requested}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) + } + return config + } + const effective = requested ?? reasoning.defaultEffort + if (effective === undefined) return config + if (!reasoning.efforts.some(effort => effort.id === effective)) { + throw new LlmError( + `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) + } + return requested === effective ? config : { ...config, reasoningEffort: effective } + } + + /** + * Resolve one call under its current adapter registration. The returned + * one-shot handle keeps that registration across header logging and dispatch, + * so HMR cannot combine one adapter's capability result with another adapter. + * @param config - provider/model route and optional request controls. + * @param signal - optional cancellation for adapter-owned capability lookup. + * @returns a prepared config and its registration-bound stream entry point. + */ + async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise { + const registration = this.registration(config.provider) + const resolvedConfig = deepFreeze(structuredClone( + await this.resolveCallConfigFor(registration, config, signal), + )) + let dispatched = false + return Object.freeze({ + config: resolvedConfig, + stream: (options: GenerateOptions): AsyncIterable => { + if (dispatched) { + throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') + } + dispatched = true + return this.streamWithRegistration(options, { registration, config: resolvedConfig }) + }, + }) + } + + private registration(provider: string): AdapterRegistration { const registration = this.adapters.get(provider) if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER') return registration @@ -295,11 +454,27 @@ export class LlmService extends Service { private async * adapterStream( options: GenerateOptions, failures: AdapterFailureScope, + prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, ): AsyncGenerator { let iterator: AsyncIterator try { - const adapter = this.registration(options.provider).adapter - const stream = adapter.stream(this.forAdapter(options, adapter)) + const registration = prepared?.registration ?? this.registration(options.provider) + const resolvedConfig = prepared === undefined + ? await this.resolveCallConfigFor(registration, options, options.signal) + : prepared.config + if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) { + throw new LlmError( + 'prepared LLM call config changed before adapter dispatch', + 'INVALID_PREPARED_CALL', + ) + } + const resolvedOptions = prepared !== undefined || callConfigEquals(options, resolvedConfig) + ? options + : Object.isFrozen(options) + ? deepFreeze({ ...options, ...resolvedConfig }) + : { ...options, ...resolvedConfig } + const adapter = registration.adapter + const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { throw markLlmAdapterFailure(failures, error) @@ -339,18 +514,36 @@ export class LlmService extends Service { * `LlmError` with code `NO_ADAPTER` if no adapter is registered for * `options.provider`. Replay state is retained only when the same adapter * instance owns its historical provider and the target provider. Final - * adapter selection, dispatch, and iteration failures retain their original - * Error identity and are tagged in a call-local scope for narrow agent-loop - * request recovery; middleware and nested-call failures remain untagged for - * the outer call. + * adapter selection remains fixed through asynchronous exact-model resolution + * and dispatch. Selection, dispatch, and iteration failures retain their + * original Error identity and are tagged in a call-local scope for narrow + * agent-loop request recovery; middleware and nested-call failures remain + * untagged for the outer call. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { + return this.streamWithRegistration(options) + } + + private streamWithRegistration( + options: GenerateOptions, + prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, + ): AsyncIterable { const failures: AdapterFailureScope = new WeakMap() - const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures)) + const stream = this.ctx.waterfall( + this, + 'llm/stream', + options, + () => this.adapterStream(options, failures, prepared), + ) return bindAdapterFailureScope(stream, failures) } } +interface AdapterRegistration { + readonly adapter: LlmAdapter + readonly provider: LlmProviderInfo +} + export default LlmService diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index f9f97d532a..6ae8008bf6 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,7 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { CallId, ProviderRequestId } from './brand.ts' +import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts' /** Serializable provider-boundary facts; policy decides whether they are retryable. */ export interface LlmFailure { @@ -161,6 +161,35 @@ export interface LlmModelContext { contextWindow: number } +/** Display metadata for one adapter-owned reasoning effort. */ +export interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} + +/** Selectable reasoning efforts for one exact provider/model route. */ +export interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} + +/** Exact-route model metadata resolved by its owning adapter. */ +export interface LlmResolvedModelInfo extends LlmModelInfo { + /** Provider-owned context capacity when known. */ + context?: LlmModelContext + /** Adapter-owned selectable reasoning levels when exposed. */ + reasoning?: LlmModelReasoningInfo +} + /** * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the @@ -201,6 +230,8 @@ export interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as diff --git a/packages/llm/llm/tests/call-config.spec.ts b/packages/llm/llm/tests/call-config.spec.ts index a5426e815f..2237d4639f 100644 --- a/packages/llm/llm/tests/call-config.spec.ts +++ b/packages/llm/llm/tests/call-config.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest' import { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from '../src/call-config.ts' +import { ReasoningEffortId } from '../src/brand.ts' import type { GenerateOptions } from '../src/types.ts' describe('callConfigEquals', () => { @@ -14,6 +15,11 @@ describe('callConfigEquals', () => { expect(callConfigEquals(base, base)).toBe(true) expect(callConfigEquals(base, { provider: 'x', model: 'm' })).toBe(false) expect(callConfigEquals(base, { provider: 'p', model: 'x' })).toBe(false) + expect(callConfigEquals({ ...base, reasoningEffort: ReasoningEffortId('high') }, base)).toBe(false) + expect(callConfigEquals( + { ...base, reasoningEffort: ReasoningEffortId('high') }, + { ...base, reasoningEffort: ReasoningEffortId('high') }, + )).toBe(true) expect(callConfigEquals({ ...base, temperature: 0.5 }, base)).toBe(false) expect(callConfigEquals({ ...base, maxTokens: 1 }, { ...base, maxTokens: 2 })).toBe(false) expect(callConfigEquals({ ...base, stop: ['a'] }, base)).toBe(false) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 9d3539494c..686bab1c17 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -11,9 +11,16 @@ import LlmService, { LlmError, llmFailureOf, ProviderRequestId, + ReasoningEffortId, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { + LlmModelContext, + LlmModelInfo, + LlmModelReasoningInfo, + LlmProviderInfo, + LlmResolvedModelInfo, +} from '@deepseek-ai/dsh-llm' class ScriptedAdapter extends LlmAdapter { constructor(private script: StreamChunk[]) { @@ -49,6 +56,7 @@ class CatalogAdapter extends ScriptedAdapter { private readonly provider: LlmProviderInfo, private readonly models: readonly LlmModelInfo[], private readonly contexts: Readonly> = {}, + private readonly reasoning: Readonly> = {}, ) { super(SCRIPT) } @@ -61,11 +69,17 @@ class CatalogAdapter extends ScriptedAdapter { return Promise.resolve(this.models) } - override resolveModelContext( - _provider: string, + override resolveModel( + provider: string, model: string, - ): Promise { - return Promise.resolve(this.contexts[model]) + ): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + ...this.contexts[model] === undefined ? {} : { context: this.contexts[model] }, + ...this.reasoning[model] === undefined ? {} : { reasoning: this.reasoning[model] }, + }) } } @@ -739,8 +753,32 @@ describe('LlmService', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }]) await expect(ctx.llm.listModels('plain')).resolves.toEqual([]) await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) - await expect(ctx.llm.resolveModelContext('plain', 'unlisted')).resolves.toBeUndefined() - await expect(ctx.llm.resolveModelContext('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(ctx.llm.resolveModelInfo('plain', 'unlisted')).resolves.toEqual({ + provider: 'plain', id: 'unlisted', name: 'unlisted', + }) + await expect(ctx.llm.resolveModelInfo('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + }) + + it.each([ + [{ provider: 1, id: 'model', name: 'Model' }, 'non-string provider'], + [{ provider: 'other', id: 'model', name: 'Model' }, 'mismatched provider'], + [{ provider: 'route', id: 1, name: 'Model' }, 'non-string id'], + [{ provider: 'route', id: 'other', name: 'Model' }, 'mismatched id'], + [{ provider: 'route', id: 'model', name: 1 }, 'non-string name'], + [{ provider: 'route', id: 'model', name: '' }, 'empty name'], + [{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'], + ] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new class extends ScriptedAdapter { + override resolveModel(): Promise { + return Promise.resolve(metadata as unknown as LlmResolvedModelInfo) + } + }(SCRIPT) + ctx.llm.registerAdapter(['route'], adapter) + + await expect(ctx.llm.resolveModelInfo('route', 'model')) + .rejects.toMatchObject({ code: 'INVALID_MODEL_INFO' }) }) it('resolves detached model context independently of advisory catalog membership', async () => { @@ -753,11 +791,241 @@ describe('LlmService', () => { { unlisted: source }, )) - const resolved = await ctx.llm.resolveModelContext('route', 'unlisted') - expect(resolved).toEqual({ contextWindow: 32_000 }) + const resolved = await ctx.llm.resolveModelInfo('route', 'unlisted') + expect(resolved.context).toEqual({ contextWindow: 32_000 }) source.contextWindow = 64_000 - expect(resolved).toEqual({ contextWindow: 32_000 }) - await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined() + expect(resolved.context).toEqual({ contextWindow: 32_000 }) + await expect(ctx.llm.resolveModelInfo('route', 'other')).resolves.toEqual({ + provider: 'route', id: 'other', name: 'other', + }) + }) + + it('resolves detached adapter-owned reasoning metadata and materializes its default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const source = { + efforts: [ + { id: ReasoningEffortId('standard'), name: 'Standard' }, + { id: ReasoningEffortId('ultra'), name: 'Ultra', description: 'Largest budget' }, + ], + defaultEffort: ReasoningEffortId('standard'), + } + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + { model: source }, + )) + + const resolved = await ctx.llm.resolveModelInfo('route', 'model') + expect(resolved.reasoning).toEqual(source) + source.efforts[0]!.name = 'mutated' + expect(resolved.reasoning?.efforts[0]?.name).toBe('Standard') + await expect(ctx.llm.resolveCallConfig({ provider: 'route', model: 'model' })).resolves.toEqual({ + provider: 'route', + model: 'model', + reasoningEffort: ReasoningEffortId('standard'), + }) + const explicit = { provider: 'route', model: 'model', reasoningEffort: ReasoningEffortId('ultra') } + await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) + }) + + it.each([ + [{ efforts: [] }, 'empty effort list'], + [{ efforts: [{ id: '', name: 'Empty' }] }, 'empty id'], + [{ efforts: [{ id: 'valid', name: '' }] }, 'empty name'], + [{ efforts: [{ id: 'valid', name: 'Valid', description: 1 }] }, 'non-string description'], + [{ efforts: [{ id: 'same', name: 'One' }, { id: 'same', name: 'Two' }] }, 'duplicate id'], + [{ efforts: [{ id: 'valid', name: 'Valid' }], defaultEffort: 'other' }, 'unknown default'], + ] as const)('rejects invalid model reasoning metadata (%s: %s)', async (metadata, _label) => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + { model: metadata as unknown as LlmModelReasoningInfo }, + )) + await expect(ctx.llm.resolveModelInfo('route', 'model')) + .rejects.toMatchObject({ code: 'INVALID_MODEL_REASONING' }) + }) + + it('rejects unsupported reasoning efforts without clamping', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + { model: { efforts: [{ id: ReasoningEffortId('ultra'), name: 'Ultra' }] } }, + )) + + await expect(ctx.llm.resolveCallConfig({ + provider: 'route', + model: 'model', + reasoningEffort: ReasoningEffortId('standard'), + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + await expect(ctx.llm.resolveCallConfig({ + provider: 'route', + model: 'plain', + reasoningEffort: ReasoningEffortId('standard'), + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + }) + + it('resolves reasoning defaults at the final adapter boundary after routing middleware', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new class extends RecordingAdapter { + override resolveModel(provider: string, model: string): Promise { + const reasoning: LlmModelReasoningInfo = { + efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }], + defaultEffort: ReasoningEffortId('standard'), + } + return Promise.resolve({ + provider, + id: model, + name: model, + reasoning, + }) + } + }(SCRIPT) + ctx.llm.registerAdapter(['routed'], adapter) + const disposeRouting = ctx.on('llm/stream', (options, next) => { + options.provider = 'routed' + return next() + }) + + for await (const _chunk of ctx.llm.stream({ + provider: 'initial', + model: 'model', + messages: [], + })) { /* drain */ } + + expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard')) + disposeRouting() + + const frozenRequest: GenerateOptions = Object.freeze({ + provider: 'routed', + model: 'model', + messages: [], + }) + for await (const _chunk of ctx.llm.stream(frozenRequest)) { /* drain */ } + expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard')) + expect(Object.isFrozen(adapter.lastOptions)).toBe(true) + }) + + it('pins one adapter registration across asynchronous exact-model resolution and dispatch', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const started = Promise.withResolvers() + const reasoning = Promise.withResolvers() + const first = new class extends RecordingAdapter { + override async resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise { + started.resolve(undefined) + return { + provider, + id: model, + name: model, + reasoning: await reasoning.promise, + } + } + }(SCRIPT) + const disposeFirst = ctx.llm.registerAdapter(['route'], first) + const draining = (async () => { + for await (const _chunk of ctx.llm.stream({ + provider: 'route', + model: 'model', + messages: [], + })) { /* drain */ } + })() + + await started.promise + disposeFirst() + const second = new RecordingAdapter(SCRIPT) + ctx.llm.registerAdapter(['route'], second) + reasoning.resolve({ + efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], + defaultEffort: ReasoningEffortId('high'), + }) + await draining + + expect(first.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('high')) + expect(second.lastOptions).toBeUndefined() + }) + + it('prepares a one-shot registration-bound call and rejects config drift', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + { + model: { + efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], + defaultEffort: ReasoningEffortId('high'), + }, + }, + ) + ctx.llm.registerAdapter(['route'], adapter) + const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) + expect(Object.isFrozen(prepared.config)).toBe(true) + const stream = prepared.stream({ + ...prepared.config, + model: 'other', + messages: [], + }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toMatchObject({ code: 'INVALID_PREPARED_CALL' }) + expect(() => prepared.stream({ + ...prepared.config, + messages: [], + })).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' })) + }) + + it('passes cancellation through exact-model resolution', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const started = Promise.withResolvers() + const adapter = new class extends ScriptedAdapter { + override resolveModel( + _provider: string, + _model: string, + signal?: AbortSignal, + ): Promise { + started.resolve(undefined) + return new Promise((_resolve, reject) => { + if (signal === undefined) { + reject(new Error('missing reasoning signal')) + return + } + if (signal.aborted) { + reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted')) + return + } + signal.addEventListener('abort', () => { + reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted')) + }, { once: true }) + }) + } + }(SCRIPT) + ctx.llm.registerAdapter(['route'], adapter) + const controller = new AbortController() + const resolving = ctx.llm.resolveCallConfig( + { provider: 'route', model: 'model' }, + controller.signal, + ) + + await started.promise + const reason = new Error('cancel reasoning') + controller.abort(reason) + await expect(resolving).rejects.toBe(reason) }) it.each([0, -1, 1.5, Number.NaN])( @@ -770,7 +1038,7 @@ describe('LlmService', () => { [], { model: { contextWindow } }, )) - await expect(ctx.llm.resolveModelContext('route', 'model')) + await expect(ctx.llm.resolveModelInfo('route', 'model')) .rejects.toMatchObject({ code: 'INVALID_MODEL_CONTEXT' }) }, ) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index f5a86d1808..702406747b 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -1,6 +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 -README.md: ccb18d725feaa397520f5ee17e2900355e7d08c2 -README.zh.md: 6ab48b0f5a704fa85e4bceffd886490462287f6a +# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md +README.md: 578728ded9cf51a12abcd70d541404e995028f26 +README.zh.md: 51518e98c43e740822970c1a39154f550ea962dc diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index ccb18d725f..578728ded9 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -6,7 +6,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I ## Configuration -The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelContext()`. +The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelInfo().context`. ## Measurement contract diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 6ab48b0f5a..51518e98c4 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -6,7 +6,7 @@ ## 配置 -估算器没有设置。它有意使用一项固定启发式规则:每个 token 按四个字符估算,再加上角色、块与请求 envelope 字段的结构开销。任何 key 都会被拒绝,包括已废弃的全局 `contextWindow`;模型容量属于拥有精确提供方/模型路由的适配器,可通过 `ctx.llm.resolveModelContext()` 获取。 +估算器没有设置。它有意使用一项固定启发式规则:每个 token 按四个字符估算,再加上角色、块与请求 envelope 字段的结构开销。任何 key 都会被拒绝,包括已废弃的全局 `contextWindow`;模型容量属于拥有精确提供方/模型路由的适配器,可通过 `ctx.llm.resolveModelInfo().context` 获取。 ## 测量契约 diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index dcef438cf7..a459427a78 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-local/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 -README.md: 877c131ca4e34fdce59a46f820b889a1b9a73555 -README.zh.md: 58cf5a0558c680abd12b599ac7ef7696ce044877 +README.md: 85254eea2bb74df277be6fd5de1529b5da4ea178 +README.zh.md: 290601d455ba2012d0c1f5c97503ae87304881cb diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 877c131ca4..85254eea2b 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -12,7 +12,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. -- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible. +- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration @@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v |---|---|---| | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | -| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. | | `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | | `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | | `configuration` | `null` | Static answer to every `workspace/configuration` item. | diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index 58cf5a0558..290601d455 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -12,7 +12,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) - 每个 `(server id, canonical workspace realpath)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。 - 每次查询都使用兼容性优先的**临时打开** 序列:通过 Node API 规范化并读取源文件、`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。 - 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。 -- 协议 shutdown 失败后,通过 POSIX 进程组信号或同步 Windows `taskkill /T /F` 终止服务器后代树。Windows 只抑制 taskkill 报告的树已不存在结果;命令、权限与其他树终止失败仍保持可见。 +- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。 - 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。 ## 配置 @@ -23,7 +23,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) |---|---|---| | `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 | | `args` | `[]` | 传给可执行文件的参数。 | -| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发)。 | +| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 | | `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 | | `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 | | `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 | diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 6815b1c917..1d6499d9f8 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-lsp": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -42,6 +43,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7", "typescript": "^6.0.3", diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 1103c4dbd2..e19718eed6 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -1,16 +1,17 @@ /** - * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound - * requests/notifications, and inbound server→client requests: it answers `workspace/configuration` - * from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs - * commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the - * child handle so the instance owns process-signal teardown. + * A JSON-RPC endpoint over one language server spawned through the subprocess + * seam. Owns id correlation, outbound requests/notifications, and inbound + * server→client requests: it answers `workspace/configuration` from static + * config, and rejects `workspace/applyEdit` (this host never applies edits or + * runs commands). It caps stderr, surfaces framing/decoder failures as a + * fatal close, and exposes tree-scoped termination through the handle so the + * instance owns teardown; group/tree mechanics live in the seam's + * implementation. * @module @deepseek-ai/dsh-lsp-local/connection */ -import type { ChildProcessByStdio } from 'node:child_process' -import { spawn, spawnSync } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import type { Writable } from 'node:stream' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' /** How to launch the server and answer its config requests. */ @@ -27,6 +28,12 @@ export interface ConnectionSpec { readonly maxMessageBytes: number /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number + /** + * The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of + * {@link LspConnection.terminate}'s escalation, and the bound for draining + * pipes a surviving helper still holds after the server exits. + */ + readonly killGraceMs: number /** Static answer to every `workspace/configuration` item. */ readonly configuration: unknown } @@ -48,178 +55,92 @@ export type ConnectionWriter = ( done: (error?: Error | null) => void, ) => void -/** Host operations used to signal a detached process tree. */ -export interface ProcessTreeOperations { - /** Signal a POSIX process group. */ - readonly signal: (target: number, signal: NodeJS.Signals) => void - /** Signal the direct child when POSIX group signaling is unavailable. */ - readonly killChild: (signal: NodeJS.Signals) => void - /** Terminate a Windows process tree by root pid. */ - readonly taskkill: (pid: number) => void -} - -/** Narrow taskkill runner result used by the Windows process-tree adapter. */ -export interface TaskkillResult { - /** Process exit status, or null when spawning failed. */ - readonly status: number | null - /** Spawn failure, when the executable could not run. */ - readonly error?: Error -} - -/** Invoke a command synchronously for the Windows taskkill adapter. */ -export type TaskkillRunner = ( - command: string, - args: string[], - options: { stdio: 'ignore' }, -) => TaskkillResult - -/** Invoke the host process-signal primitive for a POSIX process group. */ -export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean - -const processSignalRunner: ProcessSignalRunner = process.kill.bind(process) - -/** taskkill status for "process not found": the requested process tree is already absent. */ -const TASKKILL_TREE_NOT_FOUND_STATUS = 128 +/** Spawn one subprocess for this connection (the provider passes `ctx.subprocess.spawn`). */ +export type ConnectionSpawner = (spec: SubprocessSpawnSpec) => SubprocessHandle const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { stdin.write(encodeMessage(message), done) } -/** - * Terminate one Windows process tree and wait for taskkill to finish. - * @param pid - root process id. - * @param run - command runner; tests inject results without requiring Windows. - */ -export function taskkillProcessTree( - pid: number, - run: TaskkillRunner = spawnSync, -): void { - const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) - if (result.error !== undefined) throw result.error - if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return - if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`) -} - -/** - * Signal one POSIX process group through an injectable host primitive. - * @param target - negative process-group id. - * @param signal - requested signal. - * @param run - host signal runner; tests inject it without touching real processes. - */ -export function signalProcessGroup( - target: number, - signal: NodeJS.Signals, - run: ProcessSignalRunner = processSignalRunner, -): void { - run(target, signal) -} - -/** - * Wait until a process-tree liveness probe reports exit. - * @param isAlive - process-tree liveness probe. - * @param signal - optional bound for the wait. - * @param yieldNow - event-loop yield primitive. - * @returns `true` when the tree exited, or `false` when the signal aborted first. - */ -export async function waitForTreeExit( - isAlive: () => boolean, - signal?: AbortSignal, - yieldNow: () => Promise = yieldToEventLoop, -): Promise { - while (isAlive()) { - if (signal?.aborted) return false - await yieldNow() - } - return true -} - -/** - * Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct - * child; Windows requires taskkill to reach the full tree. - * @param platform - host platform. - * @param pid - detached root process id. - * @param signal - requested termination signal. - * @param operations - host operations. - */ -export function signalProcessTree( - platform: NodeJS.Platform, - pid: number, - signal: NodeJS.Signals, - operations: ProcessTreeOperations, -): void { - if (platform === 'win32') { - operations.taskkill(pid) - return - } - try { - operations.signal(-pid, signal) - } catch { - try { - operations.killChild(signal) - } catch { - // The direct child already exited; teardown remains idempotent. - } - } -} - /** A live JSON-RPC endpoint bound to one child process. */ export class LspConnection { - private readonly child: ChildProcessByStdio + private readonly handle: SubprocessHandle + private readonly stdin: Writable private readonly decoder: MessageDecoder private readonly pending = new Map() private nextId = 1 - private stderr = Buffer.alloc(0) private closeReason: Error | undefined /** Set once the process has fully exited; the instance awaits it during teardown. */ readonly closed: Promise /** * @param spec - how to launch the server and answer its config requests. + * @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`). * @param onServerRequest - answers a server→client request; rejects to send an error response. * @param writer - message writer; tests inject callback failures without relying on OS pipe races. */ constructor( - private readonly spec: ConnectionSpec, + spec: ConnectionSpec, + spawner: ConnectionSpawner, private readonly onServerRequest: (method: string, params: unknown) => Promise, private readonly writer: ConnectionWriter = writeConnectionMessage, ) { this.decoder = new MessageDecoder(spec.maxMessageBytes) - // `detached` gives teardown a process-tree root: POSIX signals its negative process-group id, - // while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it. - this.child = spawn(spec.command, [...spec.args], { + // stdin/stdout are piped protocol streams this endpoint frames itself; + // stderr is a collected diagnostic tail (no spill — the bounded tail IS + // the contract). The seam owns detachment and tree-scoped signalling. + this.handle = spawner({ + argv: [spec.command, ...spec.args], cwd: spec.cwd, + stdio: { + stdin: 'pipe', + stdout: 'pipe', + stderr: { maxBytes: spec.maxStderrBytes }, + }, + graceMs: spec.killGraceMs, + // spec.env mixes the scrubbed base with explicit config entries; the + // seam merges the whole map after its own ambient scrub, so a + // configured DSH_* fact reaches the child. env: spec.env, - stdio: ['pipe', 'pipe', 'pipe'], - detached: true, }) + /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ + if (this.handle.stdin === undefined || this.handle.stdout === undefined) { + throw new Error('lsp-local: subprocess implementation dropped a piped protocol stream') + } + /* v8 ignore stop */ + this.stdin = this.handle.stdin this.closed = new Promise((resolve) => { - this.child.on('close', () => { + const close = (): void => { const reason = this.closeReason ?? new Error(this.exitMessage()) // Record the reason so any request issued AFTER close rejects immediately instead of hanging // (a closed process sends no further responses). this.closeReason = reason this.failAll(reason) resolve() + } + this.handle.done.then(close, (error: unknown) => { + // A spawn-level failure never produces a close event; the rejection is + // the fatal cause and the close boundary at once. + this.fail(asError(error)) + close() }) }) - this.child.on('error', (error) => { this.fail(error) }) // Child stdin can fail while the process itself remains alive (for example, a server closes fd // 0). Treat that as a fatal connection error so pending requests reject immediately instead of // waiting for a process-close event that may never arrive. - this.child.stdin.on('error', (error) => { this.fail(error) }) - this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) - this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) + this.stdin.on('error', (error) => { this.fail(error) }) + this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) } /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */ get pid(): number { - /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */ - return this.child.pid ?? -1 + return this.handle.pid } /** The retained stderr tail, for diagnostics on a failed server. */ get stderrTail(): string { - return this.stderr.toString('utf8') + /* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */ + return this.handle.collected.stderr?.readFrom(0).text ?? '' } /** Whether the transport has failed even if the child close event has not arrived yet. */ @@ -289,14 +210,9 @@ export class LspConnection { return this.nextId } - /** Request termination of the server's process tree. */ + /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ terminate(): void { - this.signalTree('SIGTERM') - } - - /** Force termination of the server's process tree. */ - kill(): void { - this.signalTree('SIGKILL') + this.handle.terminate() } /** @@ -305,39 +221,7 @@ export class LspConnection { * @returns `true` when the tree exited, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { - return await waitForTreeExit(this.processTreeAlive.bind(this), signal) - } - - /** Signal the whole process tree. */ - private signalTree(sig: NodeJS.Signals): void { - const pid = this.child.pid - if (pid === undefined) return - signalProcessTree(process.platform, pid, sig, { - signal: signalProcessGroup, - killChild: this.child.kill.bind(this.child), - taskkill: taskkillProcessTree, - }) - } - - /** Whether the detached tree's root or POSIX process group is still alive. */ - private processTreeAlive(): boolean { - const pid = this.child.pid - /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ - if (pid === undefined) return false - try { - process.kill(-pid, 0) - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - /* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes - whether lifecycle tests observe this branch platform-dependent. */ - if (code === 'ESRCH') return false - /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs - process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ - if (code === 'EPERM') return true - return this.child.exitCode === null && this.child.signalCode === null - /* v8 ignore stop */ - } + return await this.handle.waitForExit(signal) } private onStdout(chunk: Buffer): void { @@ -346,30 +230,15 @@ export class LspConnection { messages = this.decoder.push(chunk) } catch (error) { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and - // SIGKILL the whole group so helper processes don't outlive the leader. + // terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then + // the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window). this.fail(asError(error)) - this.signalTree('SIGKILL') + this.handle.terminate() return } for (const message of messages) this.dispatch(message) } - private onStderr(chunk: Buffer): void { - // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just - // before it exits, so the final bounded segment is the useful one. - const cap = this.spec.maxStderrBytes - if (chunk.length >= cap) { - // Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer. - this.stderr = Buffer.from(chunk.subarray(chunk.length - cap)) - return - } - const retainedBytes = Math.min(this.stderr.length, cap - chunk.length) - this.stderr = Buffer.concat([ - this.stderr.subarray(this.stderr.length - retainedBytes), - chunk, - ], retainedBytes + chunk.length) - } - private dispatch(message: unknown): void { if (message === null || typeof message !== 'object') return const frame = message as Record @@ -423,7 +292,7 @@ export class LspConnection { reject(error) } try { - this.writer(this.child.stdin, message, done) + this.writer(this.stdin, message, done) /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a nonconforming Writable implementation throwing synchronously. */ } catch (error) { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index dda3558130..d926ae9428 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -25,6 +25,8 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { canonicalizeWorkspace, readHostSource } from './host.ts' import { LspInstance } from './instance.ts' +import type { ConnectionSpawner } from './connection.ts' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { InstanceSpec } from './instance.ts' export { canonicalizeWorkspace, readHostSource } from './host.ts' @@ -44,10 +46,10 @@ export { LspConnection } from './connection.ts' export const name = 'lsp-local' /** Services required by this plugin. */ -export const inject = ['lsp'] +export const inject = ['lsp', 'subprocess'] /** Credential-shaped ambient env vars are NOT forwarded to the child by default. */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000 const DEFAULT_MAX_STDERR_BYTES = 1_000_000 @@ -127,7 +129,7 @@ export function apply(ctx: Context, config: Config): void { validateServerConfig(providerId, resolved) const childEnv = buildChildEnv(resolved.env) const executable = resolveExecutable(resolved.command, childEnv) - return new LocalLspProvider(providerId, resolved, childEnv, executable) + return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec)) }) ctx.effect(() => { @@ -189,6 +191,7 @@ class LocalLspProvider implements LspProvider { private readonly config: ResolvedServerConfig, private readonly childEnv: Record, private readonly executable: string, + private readonly spawner: ConnectionSpawner, ) { this.id = LspProviderId(providerId) this.extensionToLanguage = config.extensionToLanguage @@ -285,7 +288,7 @@ class LocalLspProvider implements LspProvider { shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } - return new LspInstance(spec) + return new LspInstance(spec, this.spawner) } /** Dispose every live instance and block further queries. */ @@ -302,12 +305,9 @@ class LocalLspProvider implements LspProvider { } } -/** The ambient env minus credential-shaped vars, plus the config's explicit env. */ +/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */ function buildChildEnv(extra: Record): Record { - const scrubbed = Object.entries(process.env).filter( - ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key), - ) as [string, string][] - return { ...Object.fromEntries(scrubbed), ...extra } + return { ...scrubbedParentEnv(), ...extra } } /** diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 266dd3c59f..c1f78eaa38 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -17,7 +17,7 @@ import type { import { deadline } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' -import type { ConnectionSpec, ConnectionWriter } from './connection.ts' +import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } from './host.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import { @@ -35,17 +35,6 @@ export interface InstanceSpec extends ConnectionSpec { readonly initializationOptions: unknown /** Graceful `shutdown`/`exit` budget before escalation (ms). */ readonly shutdownTimeoutMs: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ - readonly killGraceMs: number -} - -/** - * Force-kill a process tree only when graceful termination did not make it exit. - * @param treeExited - whether the tree exited within its grace period. - * @param forceKill - forceful process-tree termination primitive. - */ -export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void { - if (!treeExited) forceKill() } /** @@ -67,10 +56,11 @@ export class LspInstance { /** * @param spec - the launch, initialize, and teardown parameters. + * @param spawner - the subprocess seam's spawn function. * @param writer - optional connection writer used by transport conformance tests. */ - constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) { - this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer) + constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) { + this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer) this.ready = this.initialize() // A handshake rejection must not surface as an unhandled rejection before the first query awaits // it; queries attach the real handler. @@ -310,17 +300,14 @@ export class LspInstance { await abortable(this.connection.closed, signal) } - /** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */ + /** + * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), + * then await leader and helper exit. The awaits are unbounded on purpose: + * the seam's escalation already committed to SIGKILL, so quiescence — not + * another timer — is the postcondition disposal owes its callers. + */ private async forceTerminate(): Promise { this.connection.terminate() - const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - let treeExited: boolean - try { - treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal) - } finally { - graceDeadline[Symbol.dispose]() - } - escalateProcessTree(treeExited, this.connection.kill.bind(this.connection)) await Promise.all([ this.connection.closed, this.connection.waitForProcessTreeExit(), diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index a2da86d87c..bdd0ec1067 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -1,9 +1,9 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** @@ -16,7 +16,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' const pkgDir = fileURLToPath(new URL('..', import.meta.url)) const seamLib = join(pkgDir, '../lsp/lib/index.js') -const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) +const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js') +const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -41,8 +42,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const { Context } = await import('cordis') const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') const LspLocal = await import('@deepseek-ai/dsh-lsp-local') + const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { fake: { @@ -57,12 +60,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) await ctx.fiber.dispose() ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index aa7e819cb6..d4fc956498 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -1,18 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' -import { - signalProcessGroup, - signalProcessTree, - taskkillProcessTree, - waitForTreeExit, -} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' -import type { - ConnectionWriter, - ProcessSignalRunner, - ProcessTreeOperations, - TaskkillRunner, -} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -23,7 +14,7 @@ let open: LspConnection[] = [] afterEach(async () => { for (const conn of open) { - conn.kill() + conn.terminate() await conn.closed } open = [] @@ -39,11 +30,12 @@ function connect( command: process.execPath, args: [fixtureServer], cwd: process.cwd(), - env: { ...process.env as Record, ...env }, + env: { ...scrubbedParentEnv(), ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, + killGraceMs: 3_000, configuration: { setting: 42 }, - }, (method, params) => { + }, spawnSubprocess, (method, params) => { seen?.push({ method, params }) return onServerRequest(method, params) }) @@ -59,16 +51,25 @@ describe('LspConnection', () => { expect(conn.pid).toBeGreaterThan(0) }) + it('forwards explicit DSH_* env entries to the child', async () => { + // A configured DSH_* fact must reach the child: the seam scrubs only the + // ambient namespace, and the explicit entry merges after that scrub. The + // fixture echoes the named variable back as hover text. + const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' }) + await conn.request('initialize', { capabilities: {} }) + expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' }) + }) + it('rejects a request when the server replies with an error', async () => { const conn = connect({ LSP_FAKE_ERROR: '1' }) await conn.request('initialize', { capabilities: {} }) await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) }) - it('treats signaling an already-closed child as a teardown race', async () => { + it('treats terminating an already-closed child as a teardown race', async () => { const conn = connectScript('') await conn.closed - expect(() => { conn.kill() }).not.toThrow() + expect(() => { conn.terminate() }).not.toThrow() }) it('answers a server workspace/configuration request from static config', async () => { @@ -148,11 +149,12 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec command: process.execPath, args: ['-e', script], cwd: process.cwd(), - env: { ...process.env as Record }, + env: scrubbedParentEnv(), maxMessageBytes: 16_000_000, maxStderrBytes, + killGraceMs: 3_000, configuration: null, - }, () => Promise.resolve(null), writer) + }, spawnSubprocess, () => Promise.resolve(null), writer) open.push(conn) return conn } @@ -166,8 +168,9 @@ describe('LspConnection edge behavior', () => { env: {}, maxMessageBytes: 1000, maxStderrBytes: 1000, + killGraceMs: 3_000, configuration: null, - }, () => Promise.resolve(null)) + }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) await expect(conn.request('initialize', {})).rejects.toThrow() }) @@ -248,72 +251,6 @@ describe('LspConnection edge behavior', () => { }) }) -describe('process-tree signaling', () => { - it('forwards POSIX process-group signals through the host runner', () => { - const run: ProcessSignalRunner = vi.fn(() => true) - signalProcessGroup(-42, 'SIGKILL', run) - expect(run).toHaveBeenCalledWith(-42, 'SIGKILL') - }) - - it('waits for tree exit and stops when its bound aborts', async () => { - const isAlive = vi.fn() - .mockReturnValueOnce(true) - .mockReturnValue(false) - const yieldNow = vi.fn(() => Promise.resolve()) - await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true) - expect(yieldNow).toHaveBeenCalledOnce() - - const controller = new AbortController() - controller.abort() - await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false) - }) - - it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => { - const operations = fakeProcessTreeOperations() - signalProcessTree('win32', 42, 'SIGTERM', operations) - expect(operations.taskkill).toHaveBeenCalledWith(42) - expect(operations.signal).not.toHaveBeenCalled() - - signalProcessTree('linux', 42, 'SIGKILL', operations) - expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL') - }) - - it('surfaces a Windows taskkill failure without downgrading to the direct child', () => { - const fallback = fakeProcessTreeOperations() - vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') }) - expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/) - expect(fallback.killChild).not.toHaveBeenCalled() - }) - - it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => { - const posixGone = fakeProcessTreeOperations() - vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') }) - vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') }) - expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow() - }) - - it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => { - const success: TaskkillRunner = vi.fn(() => ({ status: 0 })) - taskkillProcessTree(42, success) - expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' }) - - expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow() - - const spawnFailure = new Error('cannot spawn taskkill') - expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure) - expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/) - }) -}) - -/** Create observable process-tree operations without touching host processes. */ -function fakeProcessTreeOperations(): ProcessTreeOperations { - return { - signal: vi.fn(), - killChild: vi.fn(), - taskkill: vi.fn(), - } -} - /** Poll a predicate until it holds or a deadline elapses. */ async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { const start = Date.now() diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 1a30ed5628..9399b5a1a7 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -58,7 +58,13 @@ function resultFor(method: string): unknown { case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) case 'textDocument/references': return envJson('LSP_FAKE_REFS', null) case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null) - case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null) + case 'textDocument/hover': { + // LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover + // contents — a test can assert exactly what env reached this process. + const echoName = process.env.LSP_FAKE_ECHO_ENV + if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` } + return envJson('LSP_FAKE_HOVER', null) + } default: return null } } diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 9f246e602a..75b9e541fc 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,9 +6,10 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' -import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -38,7 +39,7 @@ function makeInstance( command: process.execPath, args: [fixtureServer], cwd: ws, - env: { ...process.env as Record, ...env }, + env: { ...scrubbedParentEnv(), ...env }, configuration: { setting: 42 }, initializationOptions: { init: true }, maxMessageBytes: 16_000_000, @@ -46,7 +47,7 @@ function makeInstance( shutdownTimeoutMs: 200, killGraceMs: 200, ...overrides, - }, writer) + }, spawnSubprocess, writer) live.push(instance) return instance } @@ -67,7 +68,7 @@ function scriptInstance(script: string, overrides: Partial = {}): command: process.execPath, args: ['-e', script], cwd: ws, - env: { ...process.env as Record }, + env: scrubbedParentEnv(), configuration: null, initializationOptions: null, maxMessageBytes: 16_000_000, @@ -75,7 +76,7 @@ function scriptInstance(script: string, overrides: Partial = {}): shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, - }) + }, spawnSubprocess) live.push(instance) return instance } @@ -254,14 +255,6 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { - it('escalates only when the process tree survives its grace period', () => { - const forceKill = vi.fn() - escalateProcessTree(false, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - escalateProcessTree(true, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - }) - it('lets a server finish protocol exit before signal escalation', async () => { const marker = join(root, 'graceful-exit.log') const instance = makeInstance({ diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 8a556a01e9..ccd85a1e14 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -7,6 +7,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { Context } from 'cordis' import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' @@ -45,6 +46,7 @@ async function mount( ): Promise { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) const register = ctx.lsp.registerProvider.bind(ctx.lsp) const registrationSpy = captureProvider === undefined ? undefined @@ -76,6 +78,7 @@ describe('lsp-local end to end over a fake server', () => { await writeFile(join(ws, 'a.py'), 'x = 1\n') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }), @@ -318,6 +321,7 @@ describe('lsp-local end to end over a fake server', () => { it('rejects at load when the command is not found', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { missing: { diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 829a84264b..77fcbe7851 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -3,6 +3,7 @@ import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { Context } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' @@ -42,6 +43,7 @@ describe('lsp-local provider resolution', () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], @@ -54,6 +56,7 @@ describe('lsp-local provider resolution', () => { it('skips empty PATH segments and fails when the command is absent', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], @@ -67,6 +70,7 @@ describe('lsp-local provider resolution', () => { // Use a server that never emits results and dispose the plugin, then confirm queries are refused. const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) // Grab the provider instance by registering, then dispose the whole plugin fiber. const lsp = ctx.lsp const fiber = await ctx.plugin(LspLocal, config('disp', { @@ -83,6 +87,7 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive teardown budget at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-budget', { command: process.execPath, args: ['-e', ''], @@ -95,6 +100,7 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive byte cap at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-cap', { command: process.execPath, args: ['-e', ''], @@ -107,6 +113,7 @@ describe('lsp-local provider resolution', () => { it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-timer', { command: process.execPath, args: ['-e', ''], @@ -122,6 +129,7 @@ describe('lsp-local provider resolution', () => { await writeFile(notExe, 'plain text, not executable') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('abs-bad', { command: notExe, args: [], @@ -133,6 +141,7 @@ describe('lsp-local provider resolution', () => { it('rejects an executable directory as a command at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('abs-directory', { command: ws, args: [], @@ -144,6 +153,7 @@ describe('lsp-local provider resolution', () => { it('rejects an empty server table at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/) await ctx.fiber.dispose() }) @@ -151,6 +161,7 @@ describe('lsp-local provider resolution', () => { it('rejects an empty server id at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('', { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' }, @@ -161,6 +172,7 @@ describe('lsp-local provider resolution', () => { it('resolves every executable before publishing any provider', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, @@ -174,6 +186,7 @@ describe('lsp-local provider resolution', () => { it('rolls back earlier registrations when a later server conflicts', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index 8ba61c0717..fe9230ce14 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -10,6 +10,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' @@ -52,6 +53,7 @@ beforeAll(async () => { ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { typescript: { diff --git a/packages/lsp/lsp-local/tsconfig.json b/packages/lsp/lsp-local/tsconfig.json index 3a631ae288..2b106ddc81 100644 --- a/packages/lsp/lsp-local/tsconfig.json +++ b/packages/lsp/lsp-local/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../lsp" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 0a0265b490..4d790cbd6b 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url' import { Context } from 'cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' @@ -49,6 +50,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { inline: { diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3480b48e52..0d214de9e8 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -40,6 +41,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", diff --git a/packages/mcp/mcp-client/src/transport.ts b/packages/mcp/mcp-client/src/transport.ts index 6f7c584b20..f4bed91931 100644 --- a/packages/mcp/mcp-client/src/transport.ts +++ b/packages/mcp/mcp-client/src/transport.ts @@ -9,22 +9,17 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { Config } from './index.ts' /** - * Credential-shaped ambient env vars are NOT forwarded to the child by default - * (the parent harness's own secrets must not leak into a spawned process - * implicitly). Same pattern as `dsh-subagent-acp`. + * The subprocess seam's scrubbed parent env (credential-shaped and stale + * `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the + * actual spawn, so this transport shares the scrub definition rather than the + * spawn path. */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ function buildChildEnv(extra: Record): Record { - const env: Record = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value - } - return { ...env, ...extra } + return { ...scrubbedParentEnv(), ...extra } } /** diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index 668ee2c3cb..461b250297 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../core/tools" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index fb26d845e5..e86024516e 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index b466ecfc46..d768d6bfa8 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -10,6 +10,7 @@ import type { IPtyForkOptions } from 'node-pty' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -26,7 +27,6 @@ export const name = 'pty-local' /** Required services: PTY registry plus the one shared confinement policy. */ export const inject = ['pty', 'sandbox', 'sandboxPolicy'] -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i interface SandboxModeFenceState { pty: Context['pty'] sandboxPolicy: Context['sandboxPolicy'] @@ -56,12 +56,9 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { } function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value - } + // node-pty owns the spawn; the base env shares the subprocess seam's scrub. return { - ...env, + ...scrubbedParentEnv(), TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6ba3a95757..f3ed6675e7 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { } } -async function harness(mode: 'danger-full-access' | 'workspace-write') { +async function harness( + mode: 'danger-full-access' | 'workspace-write', + timing: { idleSilenceMs?: number; timeoutMs?: number } = {}, +) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) const ctx = new Context() @@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { const fiber = await ctx.plugin(ptyLocal, { pollIntervalMs: 10, exactProbeAfterMs: 20, - idleSilenceMs: 250, - timeoutMs: 2000, + idleSilenceMs: timing.idleSilenceMs ?? 250, + timeoutMs: timing.timeoutMs ?? 2_000, disposeGraceMs: 500, scrollbackLines: 100, scrollbackMaxBytes: 32_768, @@ -63,8 +66,12 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox } } -async function waitForOutput(operation: PtySendOperation, expected: string): Promise { - const deadline = Date.now() + 2_000 +// PtySendOperation.append drops output once the operation settles, so this only +// observes a marker the child prints while `operation` is still active. A caller +// whose child is slow to print must raise the harness `timing` bounds too; +// extending this deadline alone cannot recover output the operation never collected. +async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs let output = '' while (!output.includes(expected) && Date.now() < deadline) { output += operation.readOutput().delta @@ -131,20 +138,25 @@ describe('pty-local real shell', () => { expect(() => process.kill(pid, 0)).toThrow() }, 10_000) - it('cancels a raw-mode foreground process with a real SIGINT', async () => { - const { ctx, agent } = await harness('danger-full-access') + it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => { + const { ctx, agent } = await harness('danger-full-access', { + idleSilenceMs: 10_000, + timeoutMs: 15_000, + }) const created = await ctx.pty.spawn(agent, { type: 'shell' }) const controller = new AbortController() const ready = 'RAW_READY' + // Delay readiness beyond the shared harness's short send bound so this + // process test owns enough slack for loaded macOS startup and shell echo. // The interactive shell echoes the command, so only child output may contain the readiness marker. - const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\'' + const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\'' expect(command).not.toContain(ready) const foreground = ctx.pty.startSend(agent, created.sessionId, { text: command, submit: true, signal: controller.signal, }) - await waitForOutput(foreground, ready) + await waitForOutput(foreground, ready, 15_000) controller.abort() const result = await foreground.done expect(result.waitReason).toBe('stdin_read') @@ -155,5 +167,5 @@ describe('pty-local real shell', () => { expect(after.viewport).toContain('AFTER_SIGINT') expect(after.waitReason).toBe('stdin_read') await ctx.pty.kill(agent, created.sessionId) - }, 10_000) + }, 20_000) }) diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json index 45a03248db..1580e4c8a2 100644 --- a/packages/pty/pty-local/tsconfig.json +++ b/packages/pty/pty-local/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../sandbox/sandbox-policy" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts index 0fefefdffa..7d1944614d 100644 --- a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts +++ b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts @@ -90,6 +90,8 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () XDG_DATA_HOME: join(cacheRoot, 'data'), npm_config_cache: join(cacheRoot, 'npm'), ...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore }, + // A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable. + ...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {}, } await execFileAsync(name, manager.installCommand(), { cwd: root, diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 1d9c6a2be2..2884e04f72 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -35,6 +35,7 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -44,6 +45,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 48b50977f8..27ba0aa581 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -32,7 +32,10 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry summary: 'Command execution', mode: 'exclusive', required: true, - baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }], + baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' }, + { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, + ], options: [ { id: 'local', diff --git a/packages/sdk/helper/src/features/builtin/provider.ts b/packages/sdk/helper/src/features/builtin/provider.ts index 227d80088c..ba54bdc641 100644 --- a/packages/sdk/helper/src/features/builtin/provider.ts +++ b/packages/sdk/helper/src/features/builtin/provider.ts @@ -1,5 +1,5 @@ /** - * Required hand-rolled DeepSeek and custom pi-ai provider behavior. + * Required direct-fetch DeepSeek and custom pi-ai provider behavior. * * @module @deepseek-ai/dsh-helper/features/builtin/provider */ @@ -74,7 +74,7 @@ export class ProviderFeature extends ExclusiveOptionFeature { override readonly required = true override readonly options = [new DeepSeekOption(), new CustomOption()] - /** Prefer the hand-rolled adapter and its public endpoint defaults. */ + /** Prefer the direct-fetch adapter and its public endpoint defaults. */ override defaultOptions(): readonly string[] { return ['deepseek'] } diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts index 8d6b617977..3d67d194d3 100644 --- a/packages/sdk/helper/src/package-managers/package-manager.ts +++ b/packages/sdk/helper/src/package-managers/package-manager.ts @@ -5,6 +5,7 @@ */ import { execFile, spawn } from 'node:child_process' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { promisify } from 'node:util' import type { PackageJsonFile } from '../documents/package-json-file.ts' import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts' @@ -51,8 +52,14 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd: } } -/** Remove credential-shaped environment variables from spawned commands. */ -export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { +/** + * Remove credential-shaped environment variables from spawned commands. + * @param environment - source environment (injectable for tests); the default + * path shares the subprocess seam's scrub so every harness spawner drops the + * same names. + */ +export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (environment === undefined) return scrubbedParentEnv() return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name))) } diff --git a/packages/sdk/helper/tsconfig.json b/packages/sdk/helper/tsconfig.json index 18e79898c7..b1a3b7a61a 100644 --- a/packages/sdk/helper/tsconfig.json +++ b/packages/sdk/helper/tsconfig.json @@ -33,6 +33,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index ce1b52a248..25c7d5797d 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -1,10 +1,10 @@ -import { spawn } from 'node:child_process' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import SessionStore, { SessionId, TOOL_OUTCOME_UNKNOWN, type SessionEvent, @@ -19,22 +19,21 @@ const roots: string[] = [] const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 async function waitForMarker(path: string, expected: string): Promise { - const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS - for (;;) { - try { - const content = await readFile(path, 'utf8') - if (content === expected) return content - if (!expected.startsWith(content)) { - throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) - } - } catch (error: unknown) { + // vi.waitFor retries every callback throw, so terminal states RESOLVE out + // of the retry loop (complete marker, or content that can no longer become + // the expected marker) and only the still-in-progress states throw-to-retry. + const content = await vi.waitFor(async () => { + const current = await readFile(path, 'utf8').catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - if (Date.now() >= deadline) { - throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`) - } - await new Promise(resolve => setTimeout(resolve, 10)) + throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error }) + }) + if (current === expected || !expected.startsWith(current)) return current + throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`) + }, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS }) + if (content !== expected) { + throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) } + return content } async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> { @@ -44,26 +43,24 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker // Keep the open-before-write window deterministic: readiness is marker content, not path existence. await writeFile(marker, '') const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect' - const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { + // The SIGKILL-at-failpoint choreography stays custom: the child must die + // mid-write, so no timeout or graceful termination may reach it first. + const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], + env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdin: 'ignore', + stdout: 'ignore', + reject: false, }) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) try { const markerText = await waitForMarker(marker, expectedMarker) - const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { - child.once('close', (code, signal) => { resolve({ code, signal }) }) - }) child.kill('SIGKILL') - const exit = await closed - expect(exit).toEqual({ code: null, signal: 'SIGKILL' }) + const exit = await child + expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' }) return { root, markerText } } catch (error: unknown) { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') - throw new Error(`crash child failed: ${stderr}`, { cause: error }) + child.kill('SIGKILL') + throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error }) } } diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 7be70e0fe5..17e68d1607 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 -README.md: 5e3bddc67d213d74766a75da65cc44a21c8bb149 -README.zh.md: 4391809ee83c822fcada25f0bdc021af44be9354 +README.md: 8414836efd756f60258566ae3e4e00de2d4110d7 +README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 5e3bddc67d..8414836efd 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -10,10 +10,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend spawns its child through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 4391809ee8..d32228495c 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -10,10 +10,9 @@ subagent seam 允许 agent(智能体)把工作委派给子 agent。与 [bash | `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | -| `subagent-subprocess/` | 共享进程外机制:环境变量清理、dispose(资源释放)阶梯、隔离配置目录(纯库;不注册任何内容) | 无 | | `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则构建于 `subagent-subprocess` 库之上(凭据环境变量清理、dispose 阶梯、隔离配置目录)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 72377b5f77..998a8e7396 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/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 -README.md: d1ba03cf5256ad4889c4893bfe11af42bd627f9d -README.zh.md: 5763ee9a22c1d0bfe12c7da2b7d996911b55cc49 +README.md: efcc77c442714a83631d009efb712fa7b8f5dfa0 +README.zh.md: 41723398ffa34995282814011cd03b2fc1cc5a4b diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index d1ba03cf52..efcc77c442 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 5763ee9a22..41723398ff 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose 请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,关闭 stdin,并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM,等待 `disposeGraceMs` 后再使用 SIGKILL;Windows 会直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除名称形似凭据的环境变量,再应用显式 `config.env` 值。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 2564afa8da..b06a5e50ea 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -32,7 +32,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -47,7 +47,8 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index f8b3cd78c6..45605a8f2c 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -15,7 +15,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' -export const inject = ['subagents'] +export const inject = ['subagents', 'subprocess'] /** Config: how to spawn and drive the child ACP agent process. */ export interface Config { @@ -152,6 +152,7 @@ class AcpProvider implements SubagentProvider { env: this.config.env, disposeEofGraceMs: this.config.disposeEofGraceMs, disposeGraceMs: this.config.disposeGraceMs, + spawn: spec => this.ctx.subprocess.spawn(spec), onError: (error, stopReason) => { // The seam forbids `result` rejecting, so a child-level failure is // flattened to a stop reason — preserve it here rather than losing it. diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 730505df84..fba0403739 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -8,9 +8,8 @@ * @module @deepseek-ai/dsh-subagent-acp/run */ -import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { Readable, Writable } from 'node:stream' +import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream' import { ClientSideConnection, ndJsonStream, @@ -26,7 +25,7 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -47,9 +46,12 @@ export interface AcpRunSpec { permission: PermissionPolicy /** * Extra environment variables to ADD for the child (e.g. the child harness's - * `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see - * {@link buildChildEnv}. A value here is forwarded even if its name matches - * the credential-scrub pattern (an explicit opt-in for the child's own creds). + * `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed + * parent env. A value here is forwarded even if its name matches the + * credential-scrub pattern (an explicit opt-in for the child's own creds). + * Explicit `DSH_*` entries are deployment-owned facts for the child harness + * (e.g. `DSH_PERMISSION_MODE`); they simply merge after the scrub that + * dropped their stale ambient namesakes. */ env: Record /** @@ -65,6 +67,12 @@ export interface AcpRunSpec { * fills this from its `disposeGraceMs` config. */ disposeGraceMs: number + /** + * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the + * child rides the shared scrub, tree-scoped teardown, and service-owned + * lifetime instead of a package-local child_process path. + */ + spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle /** * Sink for a child-level failure that the run flattened into a stop reason * (the seam contract forbids `result` rejecting). The driver calls this with @@ -82,6 +90,46 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) + } +} + +/** + * Cooperative teardown ladder for an out-of-process agent, over the seam's + * public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's + * window to flush persistence and reap its own descendants), then the + * terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded + * confirmation wait. + * @param child - the spawned ACP child's handle. + * @param eofGraceMs - tier-1 window after stdin EOF. + * @param graceMs - confirmation window after the escalation's SIGKILL. + * @throws when the tree still has not exited `graceMs` after forced termination. + */ +export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise { + // A spawn failure has no process to tear down; observe the rejection so + // disposal in a finally block cannot surface it as unhandled. + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + child.stdin?.end() + if (await treeExitsWithin(child, eofGraceMs)) return + // terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace + // (this plugin passes disposeGraceMs there), so the bound covers both the + // escalation window and an equal confirmation window after the SIGKILL. + child.terminate() + if (!(await treeExitsWithin(child, graceMs * 2))) { + throw new Error('ACP child process tree did not exit within its dispose windows') + } +} + /** * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. * @param reason - the terminal reason from the child's `session/prompt` response. @@ -159,21 +207,35 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // each other or with a local agent that happens to use the same session id. const id = SessionId(randomUUID()) - // Keep diagnostics on parent stderr; only ACP output contributes to the result. - const child = spawn(spec.command, spec.args, { + // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes + // to the result. The seam's scrub drops ambient credentials and DSH_* names + // while spec.env (the child's own key, its deployment facts) merges after it. + const child = spec.spawn({ + argv: [spec.command, ...spec.args], cwd: spec.cwd, - env: buildChildEnv(spec.env), - stdio: ['pipe', 'pipe', 'inherit'], + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + env: spec.env, }) - // Capture the child-process error event immediately. - const spawnFailed = spawnFailure(child) + /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ + if (child.stdin === undefined || child.stdout === undefined) { + throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream') + } + /* v8 ignore stop */ + // Spawn-level failure surfaces as `done` rejecting into the startup race; a + // clean exit must never win it, so the success arm parks forever. (The ACP + // connection observing its streams closing bounds a child that exits + // without speaking the protocol.) + const spawnFailed: Promise = child.done.then( + /* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */ + () => new Promise(() => {}), + (err: unknown) => Promise.reject(toError(err)), + ) + spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ }) // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= disposeChildProcess(child, { - disposeEofGraceMs: spec.disposeEofGraceMs, - disposeGraceMs: spec.disposeGraceMs, - })) + const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs)) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] @@ -207,8 +269,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const conn = new ClientSideConnection( makeClient, ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, + NodeWritable.toWeb(child.stdin) as WritableStream, + NodeReadable.toWeb(child.stdout) as ReadableStream, ), ) @@ -252,7 +314,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe sessionId = returnedSessionId if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), - spawnFailed.then((err): never => { throw err }), + spawnFailed, cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), ]) } catch (error: unknown) { diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 6b3f8157e8..de5900906a 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -4,6 +4,9 @@ * fully scripted by environment variables — no model, no network: * * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. + * - `MOCK_ECHO_ENV` — if set to a variable NAME, stream that variable's value + * (or ``) instead of MOCK_TEXT — asserts what + * environment actually reached the child process. * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` * (`end_turn` default, or `max_tokens`/`refusal`/…). * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for @@ -67,7 +70,12 @@ import { type StopReason, } from '@agentclientprotocol/sdk' -const TEXT = process.env.MOCK_TEXT ?? 'mock child answer' +// When MOCK_ECHO_ENV names a variable, stream that variable's value in place +// of MOCK_TEXT — lets a test assert exactly what env reached this process. +const echoEnvName = process.env.MOCK_ECHO_ENV +const TEXT = echoEnvName !== undefined + ? process.env[echoEnvName] ?? `<${echoEnvName} unset>` + : process.env.MOCK_TEXT ?? 'mock child answer' const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1' const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason const HANG = process.env.MOCK_HANG === '1' diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 6f5a1e1c54..d361d26f4f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import * as acp from '../src/index.ts' @@ -21,7 +22,7 @@ const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cord const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) // How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE). -// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is +// The subprocess seam scrubs ambient creds while spec.env merges after it, so the model key is // forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only. const childLaunch = resolveExampleLaunch({ srcBin: binScript, @@ -52,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: childLaunch.command, @@ -81,6 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: childLaunch.command, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 79ef8831cf..163e980502 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,10 +6,11 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' -import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' /** * Keyless integration tests for the ACP subagent backend. Each spawns a REAL @@ -41,6 +42,7 @@ interface SetupEnv { async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -98,21 +100,104 @@ describe('acpContentText / toAcpPrompt', () => { }) }) -describe('buildChildEnv', () => { - it('drops credential-shaped ambient vars but keeps the explicit extras', () => { - process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me' +describe('child env layering (through the subprocess seam)', () => { + it('drops credential-shaped ambient vars but keeps the explicit extras', async () => { + process.env.ACP_TEST_AMBIENT_SECRET_TOKEN = 'leak-me' try { - const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' }) - // The credential-shaped ambient var is scrubbed. - expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined() - // The explicitly-supplied key survives (an opt-in for the child's creds). - expect(env.DEEPSEEK_API_KEY).toBe('explicit') - // A normal ambient var is forwarded. - expect(env.PATH).toBe(process.env.PATH) + // The spec.env layer merges after the seam's scrub, so the child's own + // explicitly-forwarded key survives while ambient credentials do not. + const running = spawnSubprocess({ + argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 1000, + env: { DEEPSEEK_API_KEY: 'explicit' }, + }) + await running.done + expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]') } finally { - delete process.env.DSH_ACP_TEST_SECRET_TOKEN + delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN } }) + + it('forwards explicit DSH_* config entries to the child', async () => { + // A deployment sets child-harness facts like DSH_PERMISSION_MODE in + // config.env; the seam's scrub drops only the AMBIENT namesakes, so the + // explicit entry merges after it and the child must see the value. + const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' }) + const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent + const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) + const result = await run.result + await run.dispose() + const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('') + expect(text).toBe('managed') + await ctx.fiber.dispose() + }) +}) + +describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => { + const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({ + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + + it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { + const child = bash('read -r line; exit 0') + await disposeAcpChild(child, 5_000, 200) + const outcome = await child.done + expect(outcome.exitCode).toBe(0) + expect(outcome.signal).toBeNull() + }) + + it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { + const child = bash('sleep 60') + await disposeAcpChild(child, 100, 5_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => { + const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore') + // Wait for the trap to arm so SIGTERM cannot race the default handler. + while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + await disposeAcpChild(child, 50, 2_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGKILL') + }) + + it('throws when the tree survives even the escalation window', async () => { + // A handle whose tree never exits (waitForExit only ever aborts): the + // ladder must fail loud instead of resolving over survivors. Built as a + // stub because the ladder composes only public verbs. + const never: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + terminate: () => {}, + waitForExit: (signal?: AbortSignal) => new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + } + await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) + }) + + it('observes a spawn-level rejection and returns without a process to reap', async () => { + const child = spawnSubprocess({ + argv: ['bash', '-c', 'true'], + cwd: '/nonexistent-dir-dsh-acp-ladder-test', + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined() + await expect(child.done).rejects.toThrow() + }) }) describe('cwd resolution', () => { @@ -140,6 +225,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) // A command that would create the sentinel if the child were ever spawned. await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: {} } } as unknown as Agent @@ -158,6 +244,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -185,6 +272,7 @@ describe('cwd resolution', () => { const absolute = resolve(relative) const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -204,6 +292,7 @@ describe('cwd resolution', () => { // reintroduce the launch-directory fallback this resolution removed. const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -224,6 +313,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -242,6 +332,7 @@ describe('cwd resolution', () => { it('rejects a config cwd that is not an accessible directory at load', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -283,6 +374,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) @@ -360,7 +452,7 @@ describe('dsh-subagent-acp', () => { await expect(startAcpRun( request('p', controller.signal), // `touch ` — runs only if the process is actually spawned. - { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, + { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess }, )).rejects.toThrow('aborted before the ACP child started') // The binary was never launched — no sentinel. expect(existsSync(sentinel)).toBe(false) @@ -385,6 +477,7 @@ describe('dsh-subagent-acp', () => { }, disposeEofGraceMs: 1000, disposeGraceMs: 100, + spawn: spawnSubprocess, })).rejects.toThrow('ACP child published without a session id') // Startup rejects only after its private child reaches quiescence. The // marker proves rollback closed stdin and allowed the child's EOF flush. @@ -412,6 +505,7 @@ describe('dsh-subagent-acp', () => { // small so the whole ladder finishes well within the 4000ms bound. disposeEofGraceMs: 150, disposeGraceMs: 150, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a @@ -459,6 +553,7 @@ describe('dsh-subagent-acp', () => { }, disposeEofGraceMs: 2000, disposeGraceMs: 50, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) // Wait until the child is fully booted with its prompt in flight (its ACP @@ -492,6 +587,7 @@ describe('dsh-subagent-acp', () => { // Tiny EOF grace so the ignored-EOF window elapses quickly. disposeEofGraceMs: 150, disposeGraceMs: 2000, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) await waitForFile(ready) @@ -587,7 +683,7 @@ describe('dsh-subagent-acp', () => { it('rejects a spawn failure after provider-owned cleanup', async () => { await expect(startAcpRun( request(), - { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, + { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess }, )).rejects.toThrow() }) @@ -601,6 +697,7 @@ describe('dsh-subagent-acp', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -626,6 +723,7 @@ describe('dsh-subagent-acp', () => { for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) await ctx.fiber.dispose() @@ -635,6 +733,7 @@ describe('dsh-subagent-acp', () => { it('rejects a startup failure via the provider load path', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: '/nonexistent/acp-agent-binary', @@ -661,6 +760,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: spawnSubprocess, onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, }, ) @@ -699,6 +799,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: spawnSubprocess, onError: () => { throw new Error('sink boom') }, }, ) @@ -763,6 +864,7 @@ describe('dsh-subagent-acp', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) expect(ctx.subagents.list()).toEqual(['acp']) await fiber.dispose() @@ -772,7 +874,7 @@ describe('dsh-subagent-acp', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in acp).toBe(false) expect(acp.name).toBe('subagent-acp') - expect(acp.inject).toEqual(['subagents']) + expect(acp.inject).toEqual(['subagents', 'subprocess']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(acp) as Record expect(unwrapped).toBe(acp) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 175eb78e2f..2d60858d4a 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -27,7 +27,7 @@ "path": "../subagent" }, { - "path": "../subagent-subprocess" + "path": "../../subprocess/subprocess" }, { "path": "../../support/loader-smoke" diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 7b8bfc36e2..4a2188dcc8 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' /** Cordis companion plugin name. */ -export const name = 'subagent-inprocess-invariant' +export const name = 'subagent-insubprocess-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index f429025a5d..647e0b005c 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 3b9342a90e..afa1d2a1d2 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -27,6 +28,7 @@ export async function spawnHarness(workdir: string): Promise { }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md deleted file mode 100644 index 6ae1778af1..0000000000 --- a/packages/subagent/subagent-subprocess/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# @deepseek-ai/dsh-subagent-subprocess - -English | [中文](README.zh.md) - -Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). - -Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library. - -## What it exports - -### `buildChildEnv(extra)` - -The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. - -### `spawnFailure(child)` - -Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. - -### `disposeChildProcess(child, graces)` - -The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): - -1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact; -2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`; -3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal. - -The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush. - -The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. - -### `createIsolatedConfigDir(prefix, pinnedPath?)` - -A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. - -- **Fresh (default)**: a private (0700) `mkdtemp` dir under the OS temp root; `remove()` deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent. -- **Pinned** (`pinnedPath` set): the path is returned as-is — never created, never removed. A deployment that pins a directory to share child state across runs owns that directory's lifecycle. - -## Testing - -`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end. - -## Model Experience - -Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment. -- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder. -- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal. -- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state. diff --git a/packages/subagent/subagent-subprocess/README.zh.md b/packages/subagent/subagent-subprocess/README.zh.md deleted file mode 100644 index 01847710df..0000000000 --- a/packages/subagent/subagent-subprocess/README.zh.md +++ /dev/null @@ -1,55 +0,0 @@ -# @deepseek-ai/dsh-subagent-subprocess - -[English](README.md) | 中文 - -用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent(智能体)作为子进程派生,例如 [ACP 后端](../subagent-acp/README.md)。这是纯库(无提供方、无注册、无 Config),提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。 - -每个可调项都是**参数**:dispose(资源释放)阶梯每次调用时接收宽限时间,配置目录辅助函数接收可选的固定路径。默认值位于各个消费插件的 Config 中(带默认值且经过校验的字段,可从 `cordis.yml` 修改),绝不位于本库。 - -## 导出内容 - -### `buildChildEnv(extra)` - -凭据环境变量清理采用与 [bash 执行器](../../bash/bash-local/README.md)相同的模式:子进程环境等于环境继承值移除名称形似凭据的变量(`/KEY|SECRET|TOKEN/i`)后,再把 `extra` 叠加到清理结果之后。`PATH`、`HOME`、`TMPDIR`、locale 和代理变量会保留,使子 CLI 正常运行;父级自身的秘密绝不会隐式泄漏,而显式提供的凭据(后端 `env` 配置中子进程自己的密钥)仍会传给子进程。 - -### `spawnFailure(child)` - -派生失败捕获:返回一个 promise,它会以子进程的第一个 `error` 事件兑现(绝不拒绝)。`ENOENT` 等派生失败是事件而非抛出的异常;没有监听器时 Node 会使父进程崩溃。因此,请在调用 `spawn()` 的同一个 tick 内调用此函数,并在运行结果路径中将其纳入竞速;错误命令随后会作为普通的子进程级失败结算。对于正常派生的子进程,该 promise 永不结算。 - -### `disposeChildProcess(child, graces)` - -平台感知的 dispose 阶梯只会在子进程确实退出后兑现:达到完全停稳,而不只是发出请求(见[防御性模式](../../../docs/defensive-patterns.md)): - -1. stdin EOF(如果 stdin 已建立管道),然后等待 `graces.disposeEofGraceMs`:可协作的子进程自行完全停稳,同时保留其 flush 与嵌套子进程清理; -2. 在 POSIX 上发送 `SIGTERM`,然后等待 `graces.disposeGraceMs`; -3. 强制终止:POSIX 使用 `SIGKILL`,Windows 使用 Node 映射的 `TerminateProcess`;然后最多等待 `graces.disposeGraceMs` 以确认退出。信号错误或未退出会导致 dispose 拒绝。 - -两个宽限时间(`DisposeLadderGraces`)来自消费插件的 `disposeEofGraceMs`/`disposeGraceMs` Config 字段。POSIX 在优雅信号和强制信号之后都使用 `disposeGraceMs`;Windows 跳过冗余的优雅信号,但用该值限定强制退出确认时间。EOF 窗口有意独立设置且通常更宽,因为协作式清理可能要等待捕获信号的孙进程和最后一次 flush。 - -退出等待逻辑位于该阶梯内部。无论结算结果如何,它们都会清理自己的 timer 和监听器,因此升级过程不会在子进程上累积监听器。 - -### `createIsolatedConfigDir(prefix, pinnedPath?)` - -为外部 CLI 子进程创建每次运行独立的隔离配置目录(`CLAUDE_CONFIG_DIR` / `CODEX_HOME` 式重定向的目标),使子进程行为只取决于部署配置,绝不取决于宿主上任何 `~/.claude` / `~/.codex` 式状态。返回一个 `IsolatedConfigDir` 句柄:`path` 写入子进程环境,`remove()` 在 dispose 时运行。 - -- **全新(默认)**:OS 临时根目录下的私有(0700)`mkdtemp` 目录;`remove()` 会尽力删除它,且绝不拒绝(留下临时目录胜过 dispose 失败),并且是幂等的。 -- **固定**(设置 `pinnedPath`):原样返回该路径,绝不创建、绝不移除。通过固定目录在运行间共享子进程状态的部署负责该目录的生命周期。 - -## 测试 - -`tests/subagent-subprocess.spec.ts`:环境变量清理和配置目录辅助函数使用真实进程环境与真实文件系统运行(rm 失败路径在 fs 边界注入拒绝,因为真实递归 rm 失败无法跨平台稳定触发,而且 root 会忽略权限位);退出等待和平台终止路径使用可脚本化的假子进程。[ACP 后端测试套件](../subagent-acp/README.md)会针对真实子进程端到端执行这些机制。 - -## 模型体验 - -通过基于进程的 subagent 后端间接产生影响;这些后端的子进程组合受凭据清理和隔离配置目录约束。 - -#### KV Cache 影响 - -不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 - -## 已知限制与延期工作 - -- **凭据清理基于名称**:只移除匹配 `KEY` / `SECRET` / `TOKEN` 的变量;除非后端提供更严格的环境,否则 `PASSWORD` 等名称不同的秘密仍会传入。 -- **信号只针对直接子进程**:清理依赖可协作的 CLI 在退出前回收其后代;重新托管或独立脱离的孙进程可能比该阶梯存活更久。 -- **全新配置目录的清理是尽力而为**:`rm` 失败时会在 OS 临时根目录下留下私有状态,而不会使 dispose 失败。 -- **固定配置目录完全由操作方负责**:辅助函数既不创建、校验、锁定,也不移除这些目录,因此并发运行可能共享该状态并发生竞态。 diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts deleted file mode 100644 index 47a97bafb6..0000000000 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external - * agent as a child process and must keep the parent deployment's credentials out of it, tear - * it down to quiescence, and isolate it from the host user's on-disk CLI state. This package - * registers no provider; consuming plugins own and validate every timing or path default. - * @module @deepseek-ai/dsh-subagent-subprocess - */ - -import type { ChildProcess } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -/** - * Credential-shaped ambient env vars are NOT forwarded to a child by default - * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a - * spawned process implicitly). Same pattern as the bash executor. The child - * agent needs its OWN credentials to reach a model — those are supplied - * explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER - * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental - * `AWS_SECRET_ACCESS_KEY` does not. - */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * The ambient env minus credential-shaped vars, plus the caller's explicit - * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so - * a child CLI runs normally; only credential-shaped names are dropped. - * @param extra - explicit vars layered on top AFTER the scrub, so a - * credential-shaped name supplied deliberately still reaches the child. - * @returns the environment to spawn the child with. - */ -export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value - } - return { ...env, ...extra } -} - -/** - * Capture the child's spawn-level `error` event as a promise. Call in the same tick as - * `spawn()`; otherwise an early event can be unhandled and crash the parent. - * @param child - the just-spawned child process. - * @returns a promise that RESOLVES (never rejects) with the child's first - * `error` event; for a child that spawns cleanly it never settles. - */ -export function spawnFailure(child: ChildProcess): Promise { - return new Promise((resolve) => { - child.once('error', (err) => { resolve(err) }) - }) -} - -/** - * Race the child's exit against a timer. Neither outcome leaves anything - * behind on the child: the exit listener is removed on timeout and the timer - * is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll - * loop) never accumulate listeners. - * @param child - the child process to watch. - * @param ms - the wait window in milliseconds. - * @returns `true` if the child exits within `ms` (immediately if it is - * already gone), `false` on timeout. - */ -function exitsWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) - return new Promise((resolve) => { - const onExit = (): void => { - clearTimeout(timer) - resolve(true) - } - // `.unref()` so a pending grace timer never keeps the parent's loop alive. - const timer = setTimeout(() => { - child.removeListener('exit', onExit) - resolve(false) - }, ms).unref() - child.once('exit', onExit) - }) -} - -/** - * The two grace periods of the dispose ladder, supplied per call by the - * consuming backend — each plugin carries them as defaulted, validated - * `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is - * deployment-tunable and this library hardcodes nothing. - */ -export interface DisposeLadderGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own nested subprocesses — - * before the parent escalates to platform termination. A separate (usually WIDER) - * grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative - * child's EOF-driven teardown may itself be waiting on a signal-trapping - * grandchild plus a final flush, needing more than one signal-grace of - * headroom. - */ - disposeEofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after - * `SIGKILL`; Windows applies it after the direct forced termination. - */ - disposeGraceMs: number -} - -/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */ -function forceTerminateWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise((resolve, reject) => { - let accepted = false - let settled = false - const cleanup = (): void => { - clearTimeout(timer) - child.off('exit', onExit) - child.off('error', onError) - } - const settle = (complete: () => void): void => { - if (settled) return - settled = true - cleanup() - complete() - } - const onExit = (): void => { settle(resolve) } - const onError = (error: Error): void => { settle(() => { reject(error) }) } - child.once('exit', onExit) - child.once('error', onError) - const timer = setTimeout(() => { - const disposition = accepted ? 'accepted' : 'refused' - settle(() => { - reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`)) - }) - }, ms).unref() - try { - accepted = child.kill('SIGKILL') - if (child.exitCode !== null || child.signalCode !== null) settle(resolve) - } catch (error: unknown) { - settle(() => { reject(new Error('SIGKILL failed', { cause: error })) }) - } - }) -} - -/** - * Tear a child process down to quiescence, resolving only after exit: close stdin and allow - * cooperative flush, then use the host's graceful and forced termination semantics. POSIX - * sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node - * maps both signals to `TerminateProcess`. - * - * @param child - the child process to tear down. - * @param graces - the two grace periods, from the consuming plugin's Config. - * @param platform - the host platform, injectable for unit coverage. - * @throws When forced termination errors or the child does not report exit within - * `disposeGraceMs`. - */ -export async function disposeChildProcess( - child: ChildProcess, - graces: DisposeLadderGraces, - platform: NodeJS.Platform = process.platform, -): Promise { - // Already gone: nothing to reap. - if (child.exitCode !== null || child.signalCode !== null) return - // 1. Close stdin and allow cooperative teardown and durable-state flush. - child.stdin?.end() - if (await exitsWithin(child, graces.disposeEofGraceMs)) return - // 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate. - if (platform !== 'win32') { - child.kill('SIGTERM') - if (await exitsWithin(child, graces.disposeGraceMs)) return - } - // 3. Force-kill and await a bounded exit edge. - await forceTerminateWithin(child, graces.disposeGraceMs) -} - -/** - * A per-run config directory handle for an external CLI child — the target of - * `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to - * the child's environment; call {@link remove} on dispose. - */ -export interface IsolatedConfigDir { - /** The directory to point the child at. */ - path: string - /** - * Best-effort cleanup: removes the directory (recursively) iff this handle - * CREATED it — a pinned directory is never removed. Idempotent; never - * rejects (a leftover dir under the OS temp root is preferable to a failed - * dispose). - */ - remove(): Promise -} - -/** - * An isolated config dir for one child run, independent of host CLI state. Without - * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory - * is returned unchanged and remains deployment-owned. - * - * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. - * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. - * @param pinnedPath - a deployment-pinned directory to use instead of a - * fresh one. - * @returns the directory handle: `path` for the child env, `remove()` for - * dispose. - */ -export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise { - if (pinnedPath !== undefined) { - return { - path: pinnedPath, - remove(): Promise { - // A pinned dir is deployment-owned state (config the user asked to - // persist across runs); removing it here would destroy it. No-op. - return Promise.resolve() - }, - } - } - const path = await mkdtemp(join(tmpdir(), prefix)) - return { - path, - async remove(): Promise { - try { - await rm(path, { recursive: true, force: true }) - } catch { - // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead - // child left an unreadable entry behind). - } - }, - } -} diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts deleted file mode 100644 index d674937e92..0000000000 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { existsSync } from 'node:fs' -import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { - buildChildEnv, - createIsolatedConfigDir, - disposeChildProcess, - spawnFailure, -} from '../src/index.ts' - -// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm -// failures are not portable and disappear under root, so this is the sanctioned filesystem seam. -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, rm: vi.fn(actual.rm) } -}) - -/** - * Unit tests for the shared out-of-process machinery. The env scrub and the - * isolated-config-dir helpers run against the REAL process env and REAL - * filesystem (one exception: the rm-failure path injects its rejection at the - * mocked fs boundary, see above); the exit waits and the dispose ladder run - * against a scriptable fake child so each escalation tier's timing is driven - * deterministically (the ACP backend's suite exercises the same ladder - * against real subprocesses end to end). - */ - -/** What fells a scripted {@link FakeChild}. */ -type LethalTrigger = 'eof' | NodeJS.Signals - -/** Per-scenario script for a {@link FakeChild}. */ -interface FakeChildScript { - /** - * The one trigger that makes the child exit (SIGKILL always does, - * uncatchable, like a real process). Omitted: only SIGKILL fells it. - */ - diesOn?: LethalTrigger - /** Delay (ms) between the lethal trigger and the exit event. */ - delayMs?: number - /** Complete the scripted exit inside the triggering call. */ - synchronousExit?: boolean - /** `false` models a child spawned without a stdin pipe. */ - stdin?: boolean -} - -/** - * A scriptable stand-in for a ChildProcess carrying exactly the surface the - * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the - * `exit` event. - */ -class FakeChild extends EventEmitter { - exitCode: number | null = null - signalCode: NodeJS.Signals | null = null - readonly kills: NodeJS.Signals[] = [] - stdinEnded = false - readonly stdin: { end: () => void } | null - - constructor(private readonly script: FakeChildScript = {}) { - super() - this.stdin = script.stdin === false - ? null - : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } } - } - - kill(signal: NodeJS.Signals): boolean { - this.kills.push(signal) - this.maybeDie(signal) - return true - } - - private maybeDie(trigger: LethalTrigger): void { - // SIGKILL is uncatchable — it always fells the child; any other trigger - // only when the scenario scripts it as the lethal one. - if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return - const exit = (): void => { - if (trigger === 'eof') this.exitCode = 0 - else this.signalCode = trigger - this.emit('exit', this.exitCode, this.signalCode) - } - if (this.script.synchronousExit === true) exit() - else setTimeout(exit, this.script.delayMs ?? 0) - } -} - -/** The helpers take a real ChildProcess; the fake carries the read surface. */ -function asChild(fake: FakeChild): ChildProcess { - return fake as unknown as ChildProcess -} - -describe('buildChildEnv', () => { - it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { - process.env.DSH_PROC_TEST_API_KEY = 'leak' - process.env.dsh_proc_test_secret = 'leak' - process.env.DSH_PROC_TEST_TOKEN = 'leak' - try { - const env = buildChildEnv({}) - expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined() - expect(env.dsh_proc_test_secret).toBeUndefined() - expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined() - } finally { - delete process.env.DSH_PROC_TEST_API_KEY - delete process.env.dsh_proc_test_secret - delete process.env.DSH_PROC_TEST_TOKEN - } - }) - - it('forwards normal ambient vars', () => { - expect(buildChildEnv({}).PATH).toBe(process.env.PATH) - }) - - it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => { - process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak' - try { - const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' }) - // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child. - expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit') - } finally { - delete process.env.DSH_PROC_TEST_EXTRA_TOKEN - } - }) - - it('an extra overrides the ambient value of a non-credential var', () => { - process.env.DSH_PROC_TEST_PLAIN = 'ambient' - try { - expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override') - } finally { - delete process.env.DSH_PROC_TEST_PLAIN - } - }) -}) - -describe('spawnFailure', () => { - it('resolves (never rejects) with the first error event', async () => { - const fake = new FakeChild() - const failure = spawnFailure(asChild(fake)) - const err = new Error('spawn ENOENT') - fake.emit('error', err) - await expect(failure).resolves.toBe(err) - }) - - it('never settles for a child that spawns cleanly and exits', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM' }) - const failure = spawnFailure(asChild(fake)) - fake.kill('SIGTERM') - await new Promise(resolve => fake.once('exit', () => { resolve() })) - // A clean lifecycle emits `exit`, never `error` — the capture stays - // pending forever, so a race against it is decided by the other arms. - const settled = await Promise.race([ - failure.then(() => 'settled'), - new Promise(resolve => setTimeout(() => { resolve('pending') }, 30)), - ]) - expect(settled).toBe('pending') - }) -}) - -describe('disposeChildProcess', () => { - it('returns immediately for an already-exited child (no EOF, no signals)', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(false) - expect(fake.kills).toEqual([]) - }) - - it('returns immediately for a child already dead by signal', async () => { - const fake = new FakeChild() - fake.signalCode = 'SIGKILL' - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(false) - expect(fake.kills).toEqual([]) - }) - - it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => { - const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(true) - expect(fake.kills).toEqual([]) - expect(fake.exitCode).toBe(0) - }) - - it('recognizes a child that exits synchronously on stdin EOF', async () => { - const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.exitCode).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.stdinEnded).toBe(true) - expect(fake.kills).toEqual(['SIGTERM']) - expect(fake.signalCode).toBe('SIGTERM') - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('recognizes a child that exits synchronously on SIGTERM', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - expect(fake.signalCode).toBe('SIGTERM') - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { - const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) - // Quiescence, not a request: at resolution the child has ACTUALLY exited - // (the exit event landed, despite the scripted post-SIGKILL delay). - expect(fake.signalCode).toBe('SIGKILL') - }) - - it('recognizes a child already gone when the final exit wait begins', async () => { - const fake = new FakeChild({ synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) - expect(fake.signalCode).toBe('SIGKILL') - }) - - it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - queueMicrotask(() => { - if (marker === 'exitCode') fake.exitCode = 0 - else fake.signalCode = 'SIGTERM' - }) - return true - }) - - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - }) - - it('walks the ladder for a child spawned without a stdin pipe', async () => { - const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - }) - - it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32') - expect(fake.kills).toEqual(['SIGKILL']) - expect(fake.signalCode).toBe('SIGKILL') - }) - - it('propagates a forced-termination error without waiting for the grace', async () => { - const fake = new FakeChild() - const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - fake.emit('error', failure) - return false - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, - 'win32', - )).rejects.toBe(failure) - expect(fake.kills).toEqual(['SIGKILL']) - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('wraps a synchronous forced-termination exception and removes its listeners', async () => { - const fake = new FakeChild() - const failure = new Error('invalid signal state') - vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, - 'win32', - )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure }) - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('bounds a refused forced termination that produces no error or exit', async () => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - return false - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 10 }, - 'win32', - )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused') - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('bounds an accepted forced termination that never reports exit', async () => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - return true - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 10 }, - 'win32', - )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted') - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) -}) - -describe('createIsolatedConfigDir', () => { - it('creates a fresh private mkdtemp dir under the OS temp root', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - try { - expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true) - const st = await stat(dir.path) - expect(st.isDirectory()).toBe(true) - // Windows reports synthetic POSIX mode bits; privacy comes from the - // inherited directory ACL rather than chmod-compatible mode bits. - if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700) - } finally { - await dir.remove() - } - }) - - it('creates a distinct dir per call (per-run isolation)', async () => { - const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - try { - expect(a.path).not.toBe(b.path) - } finally { - await a.remove() - await b.remove() - } - }) - - it('remove() deletes a fresh dir recursively and is idempotent', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - await writeFile(join(dir.path, 'settings.json'), '{}') - await dir.remove() - expect(existsSync(dir.path)).toBe(false) - // Second remove: nothing left to delete, still resolves. - await expect(dir.remove()).resolves.toBeUndefined() - }) - - it('returns a pinned dir verbatim and NEVER removes it', async () => { - const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-')) - try { - const dir = await createIsolatedConfigDir('ignored-prefix-', pinned) - expect(dir.path).toBe(pinned) - await dir.remove() - // The deployment owns a pinned dir's lifecycle — remove() must not touch it. - expect(existsSync(pinned)).toBe(true) - } finally { - await rm(pinned, { recursive: true, force: true }) - } - }) - - it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => { - const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`) - const dir = await createIsolatedConfigDir('ignored-prefix-', missing) - expect(dir.path).toBe(missing) - expect(existsSync(missing)).toBe(false) - await dir.remove() - expect(existsSync(missing)).toBe(false) - }) - - it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-') - try { - // The swallow contract is error-kind agnostic; EACCES stands in for the - // family (EBUSY, a vanished mount, …) that best-effort must absorb. - vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })) - await expect(dir.remove()).resolves.toBeUndefined() - // The injected rejection consumed the only rm call — nothing was deleted. - expect(existsSync(dir.path)).toBe(true) - } finally { - await rm(dir.path, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/subagent/subagent-subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml similarity index 73% rename from packages/subagent/subagent-subprocess/README.i18n.yaml rename to packages/subprocess/README.i18n.yaml index 35efba2b98..f536302eea 100644 --- a/packages/subagent/subagent-subprocess/README.i18n.yaml +++ b/packages/subprocess/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 -README.md: 6ae1778af1ca38a6c49c7f462e536a9c16c7e6bb -README.zh.md: 01847710df7c12ace7f87e45abc8e83958469740 +README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b +README.zh.md: e30b6c7f51ed0dffba15a6d1dff632e4ff4c6402 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md new file mode 100644 index 0000000000..64e4740c7a --- /dev/null +++ b/packages/subprocess/README.md @@ -0,0 +1,12 @@ +# subprocess/ — subprocess capability family + +English | [中文](README.zh.md) + +The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). + +| Package | ctx key | Role | +|---|---|---| +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | + +The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md new file mode 100644 index 0000000000..e30b6c7f51 --- /dev/null +++ b/packages/subprocess/README.zh.md @@ -0,0 +1,12 @@ +# subprocess/:进程管理能力家族 + +[English](README.md) | 中文 + +spawn 受管子进程树的共用归属位置:完全显式的 spawn spec,其 stdio 处置方式(disposition)为 Node 形状、按流划分(原始管道、inherit、附带 spill 文件的有界尾部保留收集);harness 中所有 spawn 调用方共用的那一份凭据清除;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose(资源释放)阶梯。命令默认值补全、shell 语义、deadline、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 + +| 包(package) | ctx 键 | 角色 | +|---|---|---| +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | + +服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml new file mode 100644 index 0000000000..b778e094f4 --- /dev/null +++ b/packages/subprocess/subprocess-local/README.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 +README.md: a4e20b5c64afd14cb9dce9f8cc46fa96a1f5f19d +README.zh.md: 2bd7ebe7c3407a4ad6a86c520594465f7f1e710c diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md new file mode 100644 index 0000000000..a4e20b5c64 --- /dev/null +++ b/packages/subprocess/subprocess-local/README.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-subprocess-local + +English | [中文](README.zh.md) + +Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)). + +## Behavior (and where it came from) + +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. +- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. +- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. + +## Model Experience + +Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix. +- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. +- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. + +The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md new file mode 100644 index 0000000000..2bd7ebe7c3 --- /dev/null +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-subprocess-local + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程树 spawn,依照 spec 中按流划分的 stdio 处置方式(disposition)完成接线(原始管道、inherit、附带可选 spill 文件的有界尾部保留收集),并以进程树为范围、按 SIGTERM→SIGKILL 升级发送信号。该实现没有任何配置:每项处置方式、限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在各调用方 seam 的配置里([`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)、[`dsh-subagent-acp`](../../subagent/subagent-acp/README.md))。 + +## 行为(以及设计来源) + +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 +- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 +- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 + +## 模型体验 + +通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归消费方所有。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。 +- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 +- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 + +原始进程处理位于 `src/spawn.ts`;`src/index.ts` 负责服务接线。 diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subprocess/subprocess-local/package.json similarity index 73% rename from packages/subagent/subagent-subprocess/package.json rename to packages/subprocess/subprocess-local/package.json index bd573b3c0c..72ff50c422 100644 --- a/packages/subagent/subagent-subprocess/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-subagent-subprocess", - "description": "Shared out-of-process subagent machinery: credential env scrub, spawn-failure capture, child-exit waits, the EOF-to-SIGTERM-to-SIGKILL dispose ladder, and isolated config dirs (pure lib; registers nothing)", + "name": "@deepseek-ai/dsh-subprocess-local", + "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", "version": "0.0.1", "private": true, "type": "module", @@ -28,10 +28,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts new file mode 100644 index 0000000000..eea1f1e739 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -0,0 +1,59 @@ +/** + * Local implementation of the subprocess seam. Each spawn is a detached + * process tree with the spec's per-stream stdio dispositions; disposal + * terminates and joins live trees. It has no config: every disposition and + * limit arrives on the spec, so the deployment-varying choices stay with the + * calling seam's config (the bash executor's, the LSP host's, …). + * @module @deepseek-ai/dsh-subprocess-local + */ + +import { Context } from 'cordis' +import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { spawnSubprocess } from './spawn.ts' +import type { SpawnInternals } from './spawn.ts' + +/** + * Local subprocess service: detached process trees, Node-shaped stdio + * dispositions (raw pipes, inherit, bounded tail-keep collection with spill + * files), credential-scrubbed environment, and tree-scoped signalling with + * SIGTERM→grace→SIGKILL escalation. + */ +export class LocalSubprocessService extends SubprocessService { + /** Live handles retained only so disposal can terminate and join them. */ + private live = new Set() + /** Test seam: spill and platform knobs forwarded to spawnSubprocess. */ + internals: SpawnInternals = {} + + constructor(ctx: Context) { + super(ctx) + ctx.effect(() => async () => { + // Terminate (escalating), then await WHOLE-TREE exit — not just the + // direct child's settlement — so even a TERM-trapping descendant cannot + // outlive the fiber. + const pending: Promise[] = [] + for (const handle of this.live) { + handle.terminate() + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + } + this.live.clear() + await Promise.all(pending) + }, 'local subprocess teardown') + } + + spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + const handle = spawnSubprocess(spec, this.internals) + this.live.add(handle) + // Release ownership only once the whole TREE is gone, not at direct-child + // settlement — a TERM-trapping helper that outlives the leader must stay + // owned so teardown can still escalate it. For the common no-survivor + // case waitForExit resolves immediately after settlement. + const release = (): Promise => + handle.waitForExit().then(() => { this.live.delete(handle) }) + handle.done.then(release, release) + return handle + } +} + +export default LocalSubprocessService diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subprocess/subprocess-local/src/invariant.ts similarity index 76% rename from packages/subagent/subagent-subprocess/src/invariant.ts rename to packages/subprocess/subprocess-local/src/invariant.ts index c273ce5209..b15b2dd511 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subprocess/subprocess-local/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-subprocess`. - * @module @deepseek-ai/dsh-subagent-subprocess/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-local`. + * @module @deepseek-ai/dsh-subprocess-local/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' +const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-local' /** Cordis companion plugin name. */ -export const name = 'subagent-subprocess-invariant' +export const name = 'subprocess-local-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts new file mode 100644 index 0000000000..90d460c2c5 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -0,0 +1,465 @@ +/** + * Process plumbing for the local subprocess service: detached process-tree + * spawn with per-stream stdio dispositions, tail-keep collection with spill + * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the + * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers + * own deadlines, teardown ladders, and cause classification. + * @module dsh-subprocess-local/spawn + */ + +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import type { Readable } from 'node:stream' +import { randomBytes } from 'node:crypto' +import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as sleepMs } from 'node:timers/promises' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import type { + CollectedOutput, + SubprocessCollect, + SubprocessHandle, + SubprocessOutcome, + SubprocessOutputMode, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' + +/** + * Build a child environment: explicit caller entries merge after the scrubbed + * parent base, so a deliberately supplied credential or current `DSH_*` fact + * wins over the scrub that dropped its ambient namesake. + * @param extra - explicit caller entries, merged verbatim after the scrub. + * @returns the environment to hand to `spawn` for the child process. + */ +export function childEnv(extra?: Readonly>): NodeJS.ProcessEnv { + return { ...scrubbedParentEnv(), ...extra } +} + +/** Injectable knobs so tests can exercise spill and platform behavior deterministically. */ +export interface SpawnInternals { + /** Directory for spill files (defaults to the OS temp dir). */ + spillDir?: string + /** Windows tree-termination runner (defaults to `taskkill /PID /T /F`). */ + taskkill?: (pid: number) => void + /** Host platform override for signalling decisions. */ + platform?: NodeJS.Platform +} + +/** + * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an + * awaited teardown must keep the event loop alive until the tree really + * exits, or the parent can exit while claiming quiescence and orphan the + * survivors it promised to reap. + */ +function sleepTick(): Promise { + return sleepMs(15) +} + +let spillCounter = 0 +let defaultSpillDir: string | undefined + +/** + * The default spill location: a private (0700) per-process directory under + * the OS tmpdir, created lazily. Predictable world-readable paths would let + * other local users read command output or pre-create symlinks. + */ +function privateSpillDir(): string { + defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) + return defaultSpillDir +} + +/** + * Collects one stream with a bounded in-memory tail. With a spill cap, on + * first overflow a spill file is created and every chunk (including those + * already collected) is appended there while the full stream remains within + * the cap; without one, only the in-memory tail is ever retained (the + * diagnostic-tail shape — a language server's stderr). + * + * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the + * end of command output; the spill file covers the head. + */ +export class OutputCollector { + private chunks: Buffer[] = [] + private bytes = 0 + private dropped = false + private spillFd: number | undefined + private spillFile: string | undefined + private spillDisabled: boolean + /** Total bytes ever pushed (not just retained). */ + private total = 0 + + constructor( + private readonly maxBytes: number, + private readonly maxSpillBytes: number | undefined, + private readonly label: string, + private readonly spillDir: string, + ) { + this.spillDisabled = maxSpillBytes === undefined + } + + /** + * Ingest one stream chunk, counting it toward the whole-stream total. On + * first overflow of the in-memory cap a spill file is opened (when spilling + * is enabled) and every chunk (already-collected ones included) is appended + * there from then on; the in-memory tail then drops whole chunks from its + * head (or the head of a single over-cap chunk) until it fits the cap again. + * @param chunk - the raw bytes from one stream 'data' event. + */ + push(chunk: Buffer): void { + this.total += chunk.length + const overflows = this.bytes + chunk.length > this.maxBytes + if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) + this.chunks.push(chunk) + this.bytes += chunk.length + while (this.bytes > this.maxBytes) { + const head = this.chunks[0] as Buffer + const excess = this.bytes - this.maxBytes + if (head.length <= excess) { + // Drop the whole head chunk (length ≥ 1 is guaranteed while over cap). + this.chunks.shift() + this.bytes -= head.length + } else { + // Trim the head so the retained window is byte-exact at the cap — a + // diagnostic tail (an LSP server's stderr) must hold the LAST + // maxBytes regardless of how the stream was chunked. + this.chunks[0] = head.subarray(excess) + this.bytes -= excess + } + this.dropped = true + } + } + + /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ + private spillAll(chunk: Buffer): void { + if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) { + this.discardSpill() + return + } + if (this.spillFd === undefined) { + // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any + // existing path, symlink or not) + owner-only mode: defeats spill-path + // prediction and symlink planting in shared tmp dirs. + this.spillFile = join( + this.spillDir, + `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, + ) + this.spillFd = openSync(this.spillFile, 'wx', 0o600) + for (const prior of this.chunks) writeSync(this.spillFd, prior) + } + writeSync(this.spillFd, chunk) + } + + /** Stop spilling and remove the file once it can no longer hold the complete stream. */ + private discardSpill(): void { + const fd = this.spillFd + const file = this.spillFile + this.spillFd = undefined + this.spillFile = undefined + this.spillDisabled = true + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // Retain the descriptor so finalize can retry the failed close. + this.spillFd = fd + } + } + if (file !== undefined) { + try { + unlinkSync(file) + } catch { + // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file. + } + } + } + + /** + * Incremental read in whole-stream byte coordinates: returns everything + * pushed since `fromByte`. When `fromByte` has already slid out of the + * in-memory tail window, the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created. + */ + readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } { + const windowStart = this.total - this.bytes + const buffer = Buffer.concat(this.chunks) + const lossy = fromByte < windowStart + const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart) + return { + text: slice.toString('utf8'), + nextOffset: this.total, + lossy, + ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, + } + } + + /** + * Close the spill file once the stream has ended. A failed close (delayed + * writeback fault) stops advertising the spill path — the file may be + * missing its tail — while every in-memory read keeps working. Idempotent; + * the spawn path seals both collectors at settlement so reads after exit + * never point at a still-open file. + */ + seal(): void { + if (this.spillFd === undefined) return + try { + closeSync(this.spillFd) + } catch { + // A delayed writeback failure makes the spill unreliable; keep the + // in-memory result but stop advertising that file. + this.spillFile = undefined + } + this.spillFd = undefined + } + + /** + * Seal the spill file and return the final output. + * @returns the final collected output: tail text, truncation flag, and the spill path when intact. + */ + finalize(): CollectedOutput { + this.seal() + return { + text: Buffer.concat(this.chunks).toString('utf8'), + truncated: this.dropped, + ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, + } + } +} + +/** + * Send `sig` to a detached POSIX process group. Never throws: delivery races + * process exit and may run in a timer callback, so failures are contained and + * a non-positive pid is a no-op. + * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. + * @param sig - the signal to deliver to the whole group. + */ +export function killGroup(pid: number, sig: NodeJS.Signals): void { + if (pid <= 0) return + try { + process.kill(-pid, sig) + } catch { + // Swallow: see contract above. + } +} + +/** + * Terminate one Windows process tree with `taskkill /T /F`. Contained like + * POSIX group signalling — delivery races tree exit, so an absent tree, a + * nonzero status, or a missing taskkill binary must not break idempotent + * teardown. + * @param pid - root process id; non-positive is a no-op. + */ +export function taskkillProcessTree(pid: number): void { + if (pid <= 0) return + // Outcome deliberately unchecked: an already-absent tree (status 128), exit + // races, and a missing taskkill binary (spawnSync reports, never throws) are + // as tolerable here as ESRCH is for a POSIX group signal. + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) +} + +/** + * Signal a detached process tree with platform-correct semantics: POSIX + * signals the negative process-group id and falls back to the direct child + * when the group is gone; Windows terminates the tree via taskkill (any + * signal value force-terminates — Node maps signals to TerminateProcess). + */ +function signalTree( + platform: NodeJS.Platform, + pid: number, + sig: NodeJS.Signals, + child: ChildProcess, + taskkill: (pid: number) => void, +): void { + if (platform === 'win32') { + taskkill(pid) + return + } + /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */ + if (pid <= 0) return + try { + process.kill(-pid, sig) + } catch { + /* v8 ignore start -- the fallback needs a live child whose group signal fails + (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */ + try { + child.kill(sig) + } catch { + // The direct child already exited; teardown remains idempotent. + } + /* v8 ignore stop */ + } +} + +/** + * Spawn one isolated detached process tree with the spec's per-stream stdio + * dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome}; + * only spawn failures reject. + * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. + * @param internals - test-only spill-directory, platform, and taskkill overrides. + * @returns live subprocess handle. + */ +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { + const spillDir = internals.spillDir ?? privateSpillDir() + const platform = internals.platform ?? process.platform + const taskkill = internals.taskkill ?? taskkillProcessTree + + if (spec.signal?.aborted) { + throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) + } + const [program, ...args] = spec.argv + if (program === undefined || program.length === 0) { + throw new Error('invalid argv: expected a non-empty program name at argv[0]') + } + + const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => + mode !== 'pipe' && mode !== 'inherit' + const outMode = spec.stdio.stdout + const errMode = spec.stdio.stderr + const stdinMode = spec.stdio.stdin + + const env = childEnv(spec.env) + const child = spawn(program, args, { + cwd: spec.cwd, + env, + stdio: [ + stdinMode === 'ignore' ? 'ignore' : 'pipe', + outMode === 'inherit' ? 'inherit' : 'pipe', + errMode === 'inherit' ? 'inherit' : 'pipe', + ], + // `detached` gives teardown a tree root on POSIX (its own process group); + // Windows terminates by root pid through taskkill /T instead. + detached: platform !== 'win32', + }) + + const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => { + if (!isCollect(mode) || stream === null) return undefined + const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir) + stream.on('data', (chunk: Buffer) => { collector.push(chunk) }) + return collector + } + const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') + const stderrCollector = collectStream(errMode, child.stderr, 'stderr') + + let graceTimer: NodeJS.Timeout | undefined + let settled = false + + // Failed spawns use pid -1 so signalling remains a no-op. + const pid = child.pid ?? -1 + + /** Whether the detached tree's root (or POSIX group) is still alive. */ + const treeAlive = (): boolean => { + if (pid <= 0) return false + if (platform === 'win32') { + // Windows has no group-liveness probe; the direct child's exit is the + // observable boundary (taskkill /T already took the tree with it). + return child.exitCode === null && child.signalCode === null + } + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing + makes observing the other arm platform-dependent. */ + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return child.exitCode === null && child.signalCode === null + /* v8 ignore stop */ + } + } + + // The escalation's tier primitive (not on the handle — terminate() is the + // only consumer-facing termination verb). Guards on TREE liveness, not + // outcome settlement: a TERM-trapping helper can outlive the settled direct + // child and must stay signalable, while a fully-dead tree (possible pid + // reuse) must not be re-signalled by a later tier. + const kill = (sig: NodeJS.Signals): void => { + if (!treeAlive()) return + signalTree(platform, pid, sig, child, taskkill) + } + + const terminate = (): void => { + if (graceTimer !== undefined) return // escalation already in flight + if (!treeAlive()) return + kill('SIGTERM') + // The escalation must survive direct-child settlement — the leader dying + // does not mean the tree died — so settle does not clear this timer, and + // kill() re-probes tree liveness before force-killing. It stays ref'd: + // the pending SIGKILL is a commitment, and a parent exiting before it + // fires would orphan a trapped survivor. Self-bounds at graceMs. + graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) + } + + // The caller owns timeout classification; this layer only reacts to abort. + const onAbort = (): void => { terminate() } + spec.signal?.addEventListener('abort', onAbort, { once: true }) + + // Batch stdin is written and closed up front; process exit and captured + // output remain authoritative, so write errors (EPIPE) are best-effort. + if (typeof stdinMode === 'object' && child.stdin !== null) { + child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) + child.stdin.end(stdinMode.data) + } + + const done = new Promise((resolve, reject) => { + let pipeDrainTimer: NodeJS.Timeout | undefined + const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { + if (settled) return + settled = true + // Only harness-collected pipes are force-closed at the drain boundary; + // a 'pipe'-mode stream belongs to the caller and closes with the child. + if (stdoutCollector !== undefined) child.stdout?.destroy() + if (stderrCollector !== undefined) child.stderr?.destroy() + stdoutCollector?.seal() + stderrCollector?.seal() + cleanup() + resolve({ exitCode, signal }) + } + child.on('error', (error) => { + // No meaningful close outcome follows a spawn failure. + settled = true + cleanup() + reject(error) + }) + child.on('exit', (exitCode, signal) => { + // A surviving descendant that inherited a pipe must not hold the + // outcome open indefinitely: after exit, the same bounded grace that + // governs kills also bounds the close wait. + pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) + }) + child.on('close', settle) + function cleanup(): void { + // graceTimer deliberately NOT cleared: the SIGKILL escalation must be + // able to reach tree survivors after the direct child settles. + if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) + spec.signal?.removeEventListener('abort', onAbort) + } + }) + + const waitForExit = async (signal?: AbortSignal): Promise => { + while (treeAlive()) { + if (signal?.aborted) return false + await sleepTick() + } + return true + } + + return { + pid, + /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ + stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, + stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, + stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, + /* v8 ignore stop */ + collected: { + ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, + ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, + }, + done, + terminate, + waitForExit, + } +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts new file mode 100644 index 0000000000..ca723fdb87 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' + +function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { + return { + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, + stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, + }, + graceMs: 200, + ...overrides, + } +} + +describe('LocalSubprocessService', () => { + it('registers as ctx.subprocess and spawns managed handles', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('echo managed')) + const result = await handle.done + expect(result.exitCode).toBe(0) + expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n') + await fiber.dispose() + }) + + it('disposal kills still-running processes and awaits their exit', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('sleep 60')) + await fiber.dispose() + const outcome = await handle.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('a settled process leaves the live set (disposal does not re-kill it)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('true')) + const outcome = await handle.done + expect(outcome.exitCode).toBe(0) + await fiber.dispose() + }) + + it('disposal tolerates a handle whose spawn already failed', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) + await expect(handle.done).rejects.toThrow() + await fiber.dispose() + }) + + it('disposal contains a spawn-failure rejection that races teardown', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + // Dispose before the rejection continuation removes the handle from the + // live set, so teardown itself must swallow the rejected done. + const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) + await fiber.dispose() + await expect(handle.done).rejects.toThrow() + }) + + it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + class SecondManager extends LocalSubprocessService {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts new file mode 100644 index 0000000000..c02f6b0d55 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -0,0 +1,815 @@ +import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' +import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' + +const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ + failNextClose: { value: false }, + failNextUnlink: { value: false }, +})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + closeSync(fd: number): void { + if (failNextClose.value) { + failNextClose.value = false + throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' }) + } + actual.closeSync(fd) + }, + unlinkSync(path: Parameters[0]): void { + if (failNextUnlink.value) { + failNextUnlink.value = false + throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' }) + } + actual.unlinkSync(path) + }, + } +}) + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) + +type SpecOverrides = Partial[0]> & { + stdoutMaxBytes?: number + stderrMaxBytes?: number + maxSpillBytes?: number + stdin?: string +} + +function spec(command: string, overrides: SpecOverrides = {}) { + const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides + return { + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdio: { + stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const, + stdout: { maxBytes: stdoutMaxBytes, spill: { maxBytes: maxSpillBytes } }, + stderr: { maxBytes: stderrMaxBytes, spill: { maxBytes: maxSpillBytes } }, + }, + graceMs: 3_000, + ...rest, + } +} + +/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */ +async function waitGone(pid: number, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + process.kill(pid, 0) + } catch { + return + } + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) +} + +async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (running.collected.stdout!.readFrom(0).text.includes(expected)) return + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) +} + +/** Await settlement and project both collected streams like a batch outcome. */ +async function finish(running: SubprocessHandle) { + const outcome = await running.done + const final = (reader: SubprocessOutputReader | undefined) => { + const read = reader!.readFrom(0) + return { text: read.text, truncated: read.lossy, ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {} } + } + return { ...outcome, stdout: final(running.collected.stdout), stderr: final(running.collected.stderr) } +} + +async function waitForPidFile(path: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const pid = Number(readFileSync(path, 'utf8').trim()) + if (Number.isSafeInteger(pid) && pid > 0) return pid + } catch { + // The child shell has not written the pid file yet. + } + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) +} + +describe('spawnSubprocess', () => { + it('captures stdout on success', async () => { + const result = await finish(spawnSubprocess(spec('echo hello'))) + expect(result.exitCode).toBe(0) + expect(result.signal).toBeNull() + expect(result.stdout.text).toBe('hello\n') + expect(result.stdout.truncated).toBe(false) + expect(result.stderr.text).toBe('') + }) + + it('captures stderr separately', async () => { + const result = await finish(spawnSubprocess(spec('echo oops >&2'))) + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('') + expect(result.stderr.text).toBe('oops\n') + }) + + it('captures both streams', async () => { + const result = await finish(spawnSubprocess(spec('echo out; echo err >&2'))) + expect(result.stdout.text).toBe('out\n') + expect(result.stderr.text).toBe('err\n') + }) + + it('reports non-zero exit codes', async () => { + const result = await finish(spawnSubprocess(spec('exit 42'))) + expect(result.exitCode).toBe(42) + expect(result.signal).toBeNull() + }) + + it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => { + const result = await finish(spawnSubprocess(spec('echo "${TERM:-unset}"', { + env: { TERM: 'callers-choice' }, + }))) + expect(result.stdout.text).toBe('callers-choice\n') + }) + + it('runs in the requested cwd', async () => { + const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' }))) + expect(result.stdout.text.trim()).toMatch(/\/tmp$/) + }) + + it('kills the process group with SIGTERM when the signal fires', async () => { + // spawnSubprocess owns no timer: it kills on abort. The bash executor drives the timeout + // by firing this signal via a deadline (see executor.spec.ts); here we + // assert the kill itself lands as SIGTERM. + const controller = new AbortController() + const start = Date.now() + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) + setTimeout(() => { controller.abort('deadline') }, 100) + const result = await running.done + expect(Date.now() - start).toBeLessThan(5_000) + expect(result.signal).toBe('SIGTERM') + expect(result.exitCode).toBeNull() + }) + + it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { + const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) + await waitForStdout(running, 'ready\n') + running.terminate() + const result = await running.done + expect(result.signal).toBe('SIGKILL') + }) + + it('terminates the whole process group (grandchildren die too)', async () => { + // The subshell writes the sleep's pid then waits on it; terminating the + // group must take the sleep down with bash. + const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const grandchild = await waitForPidFile(pidFile) + expect(grandchild).toBeGreaterThan(0) + + running.terminate() + const result = await running.done + expect(result.signal).toBe('SIGTERM') + await waitGone(grandchild) + }) + + it('aborts via AbortSignal mid-run', async () => { + const controller = new AbortController() + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) + setTimeout(() => { controller.abort('user cancelled') }, 50) + const result = await running.done + expect(result.signal).toBe('SIGTERM') + }) + + it('throws when the signal is already aborted before spawn', () => { + const controller = new AbortController() + controller.abort('too late') + expect(() => spawnSubprocess(spec('echo hi', { signal: controller.signal }))) + .toThrow(/aborted before spawn: too late/) + }) + + it('rejects with a spawn error for a nonexistent cwd', async () => { + await expect(spawnSubprocess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) + .rejects.toThrow(/ENOENT/) + }) + + it('terminate() is idempotent (second call does not restart escalation)', async () => { + const running = spawnSubprocess(spec('sleep 60')) + running.terminate() + running.terminate() + const result = await running.done + expect(result.signal).toBe('SIGTERM') + }) + + it('bounds inherited-pipe draining after the shell exits', async () => { + const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) + const started = Date.now() + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) + const descendant = await waitForPidFile(pidFile) + try { + const result = await finish(running) + expect(Date.now() - started).toBeLessThan(1_000) + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('shell-done\n') + } finally { + process.kill(descendant, 'SIGKILL') + await waitGone(descendant) + } + }) +}) + +describe('stdin and extra env (set by in-process plugins)', () => { + it('writes stdin to the command and closes it', async () => { + const result = await finish(spawnSubprocess(spec('cat', { stdin: 'hello from stdin\n' }))) + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('hello from stdin\n') + }) + + it('a command that reads stdin sees EOF when none is supplied', async () => { + // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no + // output (it does NOT block). + const result = await finish(spawnSubprocess(spec('cat'))) + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('') + }) + + it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { + // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). + // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. + const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other'))) + expect(none.stdout.text).toBe('char\n') + const piped = await finish(spawnSubprocess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' }))) + expect(piped.stdout.text).toBe('socket\n') + }) + + it('merges ordinary extra env entries onto the scrubbed environment', async () => { + const result = await finish(spawnSubprocess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { + env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, + }))) + expect(result.stdout.text).toBe('alpha/beta\n') + }) + + it('an explicit extra env entry overrides the credential scrub', async () => { + // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit + // entry is still honored — the scrub only drops AMBIENT process.env creds. + const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', { + env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, + }))) + expect(result.stdout.text).toBe('explicit-wins\n') + }) + + it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { + // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. + // The handler swallows that write error and `done` reports the child's real exit. + const big = 'x'.repeat(1024 * 1024) + const result = await finish(spawnSubprocess(spec('exit 7', { stdin: big }))) + expect(result.exitCode).toBe(7) + }) +}) + +describe('output truncation and spill', () => { + it('applies stdout and stderr caps independently', async () => { + const result = await finish(spawnSubprocess( + spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { + stdoutMaxBytes: 500, + stderrMaxBytes: 100, + }), + { spillDir }, + )) + expect(result.stdout.truncated).toBe(false) + expect(result.stdout.text).toBe('x'.repeat(500)) + expect(result.stderr.truncated).toBe(true) + expect(result.stderr.text.length).toBeLessThanOrEqual(100) + }) + + it('keeps the tail and spills the full stream to disk', async () => { + // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. + const result = await finish(spawnSubprocess( + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), + { spillDir }, + )) + expect(result.stdout.truncated).toBe(true) + expect(result.stdout.text.length).toBeLessThanOrEqual(500) + expect(result.stdout.text).toContain('line-0200') + expect(result.stdout.text).not.toContain('line-0001') + expect(result.stdout.spillPath).toBeDefined() + const full = readFileSync(result.stdout.spillPath!, 'utf8') + expect(full).toContain('line-0001') + expect(full).toContain('line-0200') + }) + + it('does not truncate output exactly at the cap', async () => { + const result = await finish(spawnSubprocess( + spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), + { spillDir }, + )) + expect(result.stdout.truncated).toBe(false) + expect(result.stdout.text.length).toBe(500) + expect(result.stdout.spillPath).toBeUndefined() + }) + + it('settles with the tail and no spill path when final spill close fails', async () => { + failNextClose.value = true + const result = await finish(spawnSubprocess( + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), + { spillDir }, + )) + expect(failNextClose.value).toBe(false) + expect(result.exitCode).toBe(0) + expect(result.stdout.truncated).toBe(true) + expect(result.stdout.text).toContain('line-0200') + expect(result.stdout.spillPath).toBeUndefined() + }) +}) + +describe('OutputCollector', () => { + it('keeps the tail of a single oversized chunk', () => { + const collector = new OutputCollector(10, 100, 'test', spillDir) + collector.push(Buffer.from('0123456789abcdef')) + const out = collector.finalize() + expect(out.text).toBe('6789abcdef') + expect(out.truncated).toBe(true) + expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef') + }) + + it('retains a byte-exact tail across uneven chunk boundaries', () => { + // A diagnostic tail must be exactly the LAST maxBytes regardless of + // chunking; dropping only whole chunks would under-retain. + const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbbbb')) + collector.push(Buffer.from('cc')) + const out = collector.finalize() + expect(out.text).toBe('aabbbbbbcc') + expect(Buffer.byteLength(out.text)).toBe(10) + expect(out.truncated).toBe(true) + }) + + it('readFrom returns increments and flags lossy reads', () => { + const collector = new OutputCollector(10, 100, 'test', spillDir) + collector.push(Buffer.from('aaaaa')) + const first = collector.readFrom(0) + expect(first.text).toBe('aaaaa') + expect(first.lossy).toBe(false) + expect(first.nextOffset).toBe(5) + + collector.push(Buffer.from('bbbbb')) + const second = collector.readFrom(first.nextOffset) + expect(second.text).toBe('bbbbb') + expect(second.lossy).toBe(false) + + // Push enough to slide the window past the last offset. + collector.push(Buffer.from('c'.repeat(20))) + const third = collector.readFrom(second.nextOffset) + expect(third.lossy).toBe(true) + expect(third.text).toBe('c'.repeat(10)) + expect(third.spillPath).toBeDefined() + }) + + it('contains close failures and drops the spill path', () => { + const collector = new OutputCollector(4, 100, 'closefail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + expect(collector.readFrom(0).spillPath).toBeDefined() + + failNextClose.value = true + let out: ReturnType + expect(() => { out = collector.finalize() }).not.toThrow() + + expect(failNextClose.value).toBe(false) + expect(out!.text).toBe('bbbb') + expect(out!.truncated).toBe(true) + expect(out!.spillPath).toBeUndefined() + }) + + it('discards a spill that exceeds its configured cap', () => { + const collector = new OutputCollector(4, 8, 'bounded', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + const spillPath = collector.readFrom(0).spillPath! + expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb') + + collector.push(Buffer.from('c')) + collector.push(Buffer.from('dddd')) + const out = collector.finalize() + expect(out.text).toBe('dddd') + expect(out.truncated).toBe(true) + expect(out.spillPath).toBeUndefined() + expect(() => readFileSync(spillPath)).toThrow() + }) + + it('does not create a spill when the first overflowing chunk exceeds the cap', () => { + const collector = new OutputCollector(4, 4, 'no-spill', spillDir) + collector.push(Buffer.from('abcdefgh')) + const out = collector.finalize() + expect(out.text).toBe('efgh') + expect(out.truncated).toBe(true) + expect(out.spillPath).toBeUndefined() + }) + + it('contains cleanup failures while disabling an oversize spill', () => { + const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + const spillPath = collector.readFrom(0).spillPath! + + failNextClose.value = true + failNextUnlink.value = true + expect(() => { collector.push(Buffer.from('c')) }).not.toThrow() + expect(failNextClose.value).toBe(false) + expect(failNextUnlink.value).toBe(false) + expect(collector.finalize().spillPath).toBeUndefined() + unlinkSync(spillPath) + }) +}) + +describe('killGroup', () => { + it('ignores non-positive pids', () => { + expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow() + expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow() + }) + + it('swallows ESRCH for vanished groups', async () => { + const running = spawnSubprocess(spec('true')) + await running.done + expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() + }) + +}) + +describe('stdio dispositions', () => { + it("'pipe' exposes raw streams for caller-owned protocol decoding", async () => { + const running = spawnSubprocess({ + ...spec('cat'), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1000 } }, + }) + expect(running.stdin).toBeDefined() + expect(running.stdout).toBeDefined() + expect(running.stderr).toBeUndefined() + expect(running.collected.stdout).toBeUndefined() + expect(running.collected.stderr).toBeDefined() + + const echoed = new Promise((resolve) => { + let text = '' + running.stdout!.on('data', (chunk: Buffer) => { text += chunk.toString('utf8') }) + running.stdout!.on('end', () => { resolve(text) }) + }) + running.stdin!.end('through the pipe\n') + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(await echoed).toBe('through the pipe\n') + }) + + it('a collect mode without spill keeps only the in-memory tail (no file)', async () => { + const running = spawnSubprocess({ + ...spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 100 }, stderr: { maxBytes: 100 } }, + }, { spillDir }) + await running.done + const read = running.collected.stdout!.readFrom(0) + expect(read.lossy).toBe(true) + expect(read.text).toContain('line-0200') + expect(read.spillPath).toBeUndefined() + }) +}) + +describe('windows tree semantics (injected platform)', () => { + it('terminate routes through taskkill by root pid', async () => { + const killed: number[] = [] + const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killed.push(pid) + // Simulate the forced tree termination taskkill performs. + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone — matches taskkill's tolerated not-found status. + } + }, + }) + running.terminate() + const outcome = await running.done + expect(killed).toContain(running.pid) + expect(outcome.signal).toBe('SIGKILL') + }) + + it('waitForExit falls back to direct-child liveness where groups do not exist', async () => { + const running = spawnSubprocess(spec('true'), { spillDir, platform: 'win32', taskkill: () => {} }) + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + }) +}) + +describe('waitForExit', () => { + it('waits for the whole detached tree, not just the shell', async () => { + const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const grandchild = await waitForPidFile(pidFile) + running.terminate() + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + await expect(waitGone(grandchild, 100)).resolves.toBeUndefined() + }) + + it('an aborted wait reports false while the tree lives', async () => { + const running = spawnSubprocess(spec('sleep 60')) + const controller = new AbortController() + controller.abort() + await expect(running.waitForExit(controller.signal)).resolves.toBe(false) + running.terminate() + await running.done + }) +}) + +describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { + it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { + // The leader spawns a TERM-trapping helper with all stdio detached from + // the collected pipes, then exits: the helper holds the GROUP alive while + // the direct child settles. The escalation must still reach it. + const pidFile = join(spillDir, `survivor-${Date.now()}.pid`) + const running = spawnSubprocess(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; wait_placeholder=; exit 0`, + { graceMs: 300 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done // direct child settled; helper survives in the group + expect(() => process.kill(helper, 0)).not.toThrow() + + running.terminate() // SIGTERM (trapped) → grace → SIGKILL the group + await expect(running.waitForExit()).resolves.toBe(true) + await waitGone(helper) + }) + + it('a bounded waitForExit reports false while a survivor lives, true after escalation', async () => { + const pidFile = join(spillDir, `survivor-wait-${Date.now()}.pid`) + const running = spawnSubprocess(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, + { graceMs: 200 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done + // A consumer-owned teardown tier bounds its wait and reads the verdict. + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 100) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) + running.terminate() + await expect(running.waitForExit()).resolves.toBe(true) + expect(() => process.kill(helper, 0)).toThrow() + }) + + it('service teardown awaits tree survivors, not just handle settlement', async () => { + const { Context } = await import('cordis') + const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as InstanceType).internals = { spillDir } + const pidFile = join(spillDir, `survivor-svc-${Date.now()}.pid`) + const running = ctx.subprocess.spawn(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, + { graceMs: 200 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done + await fiber.dispose() + // Teardown itself waited for the survivor to die. + expect(() => process.kill(helper, 0)).toThrow() + }) +}) + +describe('coverage seams', () => { + it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => { + expect(() => { taskkillProcessTree(-1) }).not.toThrow() + expect(() => { taskkillProcessTree(0) }).not.toThrow() + // On POSIX there is no taskkill; spawnSync reports the failure in its + // result and the function stays silent — the same containment Windows + // relies on for an already-absent tree. + expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() + }) + + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { + const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) + await expect(running.done).rejects.toThrow() + await expect(running.waitForExit()).resolves.toBe(true) + }) + + it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => { + const running = spawnSubprocess({ + ...spec('echo to-parent; echo err >&2'), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 1000 } }, + }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(running.stdout).toBeUndefined() + expect(running.collected.stdout).toBeUndefined() + expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') + }) + + it("an 'inherit' stderr with collected stdout wires only the requested collector", async () => { + const running = spawnSubprocess({ + ...spec('echo out; echo to-parent >&2'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'inherit' }, + }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(running.stderr).toBeUndefined() + expect(running.collected.stderr).toBeUndefined() + expect(running.collected.stdout!.readFrom(0).text).toBe('out\n') + }) + + it('terminate() after the tree died delivers no termination signal', async () => { + const running = spawnSubprocess(spec('true')) + await running.done + await running.waitForExit() + const spy = vi.spyOn(process, 'kill') + try { + running.terminate() + const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) + expect(delivered).toEqual([]) + } finally { + spy.mockRestore() + } + }) + + it('waitForExit on a failed spawn reports exited immediately', async () => { + const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-spawn-test' })) + await expect(running.done).rejects.toThrow() + await expect(running.waitForExit()).resolves.toBe(true) + }) + + it('a batch-stdin handle exposes no stdin surface', async () => { + const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' })) + expect(running.stdin).toBeUndefined() + await running.done + expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n') + }) +}) + +describe('coverage seams 2', () => { + it('win32 treeAlive reports alive for a live child and gone after taskkill', async () => { + let killedPid = 0 + const running = spawnSubprocess(spec('sleep 60'), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killedPid = pid + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone. + } + }, + }) + const aborted = new AbortController() + aborted.abort() + await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch + running.terminate() + await running.done + expect(killedPid).toBe(running.pid) + await expect(running.waitForExit()).resolves.toBe(true) + }) + + it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => { + // An inert taskkill simulates a tree that never reports exit: terminate() + // delivers nothing, so a bounded consumer wait must come back false. + const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) + running.terminate() + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 60) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) + // Real cleanup: the injected platform spawned without detachment, so the + // child is a plain (group-less) POSIX process — kill it directly. + process.kill(running.pid, 'SIGKILL') + await running.done + }) + + it("stderr: 'pipe' exposes the raw stream", async () => { + const running = spawnSubprocess({ + ...spec('echo err >&2'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'pipe' }, + }) + expect(running.stderr).toBeDefined() + const text = new Promise((resolve) => { + let out = '' + running.stderr!.on('data', (chunk: Buffer) => { out += chunk.toString('utf8') }) + running.stderr!.on('end', () => { resolve(out) }) + }) + await running.done + expect(await text).toBe('err\n') + }) +}) + +describe('argv validation', () => { + it('rejects an empty argv before spawning', () => { + expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) + }) + + it('rejects an empty program name before spawning', () => { + expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) + }) + + it('spawns argv verbatim without shell interpretation', async () => { + const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] })) + expect(result.stdout.text).toBe('$HOME') + }) +}) + +describe('abort edge cases', () => { + it('reports a fallback reason for reason-less pre-aborted signals', () => { + // Real AbortControllers always set a DOMException reason; signal-like + // objects from other libraries may not — the fallback covers them. + const bare = { + aborted: true, + reason: undefined, + addEventListener() {}, + removeEventListener() {}, + } as unknown as AbortSignal + expect(() => spawnSubprocess(spec('echo hi', { signal: bare }))) + .toThrow(/aborted before spawn: aborted/) + }) + + it('reports the terminating signal of an externally self-killed command', async () => { + // spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the + // executor's classification (a self-kill is neither) — see executor.spec.ts. + const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) + expect(result.signal).toBe('SIGTERM') + }) +}) + +describe('environment and spill-file hardening', () => { + it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => { + process.env.DSH_TEST_API_KEY = 'super-secret' + process.env.DSH_TEST_TOKEN = 'also-secret' + process.env.DSH_TEST_PLAIN = 'visible' + try { + const result = await finish(spawnSubprocess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"'))) + expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') + } finally { + delete process.env.DSH_TEST_API_KEY + delete process.env.DSH_TEST_TOKEN + delete process.env.DSH_TEST_PLAIN + } + }) + + it('forwards explicit DSH_* env entries while scrubbing ambient ones', async () => { + // Both facts through one explicit map: the ambient DSH_STALE is dropped by + // the scrub, and the deliberately supplied current values merge after it. + process.env.DSH_STALE = 'old-value' + try { + const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { + env: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, + }))) + expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') + } finally { + delete process.env.DSH_STALE + } + }) + + it('creates spill files with owner-only permissions and random names', async () => { + const result = await finish(spawnSubprocess( + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), + { spillDir }, + )) + const path = result.stdout.spillPath! + expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) + const mode = statSync(path).mode & 0o777 + expect(mode).toBe(0o600) + }) + + it('defaults spills into a private per-process directory', async () => { + const result = await finish(spawnSubprocess( + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), + )) + const dir = dirname(result.stdout.spillPath!) + expect(dir).toMatch(/dsh-subprocess-/) + const mode = statSync(dir).mode & 0o777 + expect(mode).toBe(0o700) + }) + + it('killGroup never throws, even for EPERM-style failures', () => { + const spy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) + }) + try { + expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow() + } finally { + spy.mockRestore() + } + }) + + it('honors AbortSignal on background-style runs (no timeout)', async () => { + const controller = new AbortController() + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) + setTimeout(() => { controller.abort() }, 50) + const result = await running.done + expect(result.signal).toBe('SIGTERM') + }) +}) diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json new file mode 100644 index 0000000000..5a8dea211b --- /dev/null +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml new file mode 100644 index 0000000000..121d2fec86 --- /dev/null +++ b/packages/subprocess/subprocess/README.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 +README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 +README.zh.md: 888b8863cc1dfb0828a6d0c8c59a48790cb1522e diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md new file mode 100644 index 0000000000..c360437bf2 --- /dev/null +++ b/packages/subprocess/subprocess/README.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-subprocess + +English | [中文](README.zh.md) + +The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md). + +## Contract + +- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. +- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. +- Disposal of the service terminates all still-running managed processes and awaits their exit. + +See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). + +## Model Experience + +Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced. +- **Teardown ladders are consumer-owned** — the seam ships signalling verbs and the tree-liveness wait, not a canned quiesce sequence; each out-of-process consumer encodes its child's cooperation shape itself (the ACP backend's stdin-EOF-first ladder is the in-repo template). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md new file mode 100644 index 0000000000..888b8863cc --- /dev/null +++ b/packages/subprocess/subprocess/README.zh.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-subprocess + +[English](README.md) | 中文 + +进程管理器 seam(`ctx.subprocess`)。抽象的 `SubprocessService` 只暴露一个方法:`spawn(spec): SubprocessHandle`,外加所有消费方共享的词汇:完全显式的 `SubprocessSpawnSpec`、携带基于偏移量的非消费式输出读取器的 `SubprocessHandle`、`SubprocessOutcome`、`CollectedOutput`,以及受管的 `DSH_*` 环境命名空间(`DSH_ENV_PREFIX`、`DshEnvironment`)。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。 + +## 契约 + +- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 +- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 +- 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 + +参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 + +## 模型体验 + +通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归消费方所有。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。 +- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合形状(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。 diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json new file mode 100644 index 0000000000..6771c19651 --- /dev/null +++ b/packages/subprocess/subprocess/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-subprocess", + "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts new file mode 100644 index 0000000000..d52433175b --- /dev/null +++ b/packages/subprocess/subprocess/src/index.ts @@ -0,0 +1,102 @@ +/** + * The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into + * managed process trees with Node-shaped stdio dispositions — raw pipes for + * protocol streams, inherit for diagnostics, bounded spill-backed collection + * for batch output — plus tree-scoped signalling. Command defaulting, shell + * semantics, deadlines, teardown ladders, framing, and presentation belong to + * consumers; the bash executor seam is the owning template. The local implementation lives in + * `@deepseek-ai/dsh-subprocess-local`. + * @module @deepseek-ai/dsh-subprocess + */ + +import { Context, Service } from 'cordis' +import { DSH_ENV_PREFIX } from './types.ts' +import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' + +export { DSH_ENV_PREFIX } from './types.ts' +export type { + CollectedOutput, + DshEnvironment, + DshEnvironmentKey, + SubprocessCollect, + SubprocessCollectedOutputs, + SubprocessHandle, + SubprocessOutcome, + SubprocessOutputMode, + SubprocessOutputRead, + SubprocessOutputReader, + SubprocessSpawnSpec, + SubprocessStdinMode, + SubprocessStdio, +} from './types.ts' + +/** + * Credential-shaped environment names are NOT forwarded to children (the + * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned + * process implicitly). One heuristic for every in-repo spawner; a + * deliberately supplied entry survives because explicit env layers merge + * after the scrub. + */ +export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +/** + * The ambient parent environment minus credential-shaped names and minus all + * `DSH_*` names — the canonical base every harness child starts from. `PATH`, + * `HOME`, locale, and proxy variables survive, so child CLIs run normally; + * harness identity never leaks implicitly (a deliberately forwarded + * credential or current `DSH_*` fact goes through the spec's explicit `env`, + * which merges after this scrub). Exported as a plain function so spawners + * that cannot route through the service (node-pty backends, SDK-managed + * transports) share the one scrub definition. + * @returns a fresh environment object safe to hand to a child spawn. + */ +export function scrubbedParentEnv(): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value + } + return env +} + +declare module 'cordis' { + interface Context { + subprocess: SubprocessService + } +} + +/** + * Abstract subprocess service. Subclass, implement {@link spawn}, and load the + * subclass as a plugin — it registers as `ctx.subprocess` (one implementation + * per context; loading a second throws, which is cordis' standard + * duplicate-service behavior). + * + * Implementations must honor these semantics: + * - {@link spawn} returns immediately with a live handle; `done` resolves at + * process close with exit facts and rejects only for spawn-level failures. + * - Collect-mode readers are offset-based and non-consuming, so independent + * readers never consume one another's output; lossy reads report truncation + * and the spill file holding the complete stream when one exists. Piped + * streams are handed to the caller raw and never buffered here. + * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates + * SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every + * platform. {@link SubprocessHandle.waitForExit} observes whole-tree + * liveness, so a consumer-owned teardown ladder can hold each tier on real + * quiescence. + * - Disposal of the service terminates all still-running managed processes + * and awaits their exit. + */ +export abstract class SubprocessService extends Service { + constructor(ctx: Context) { + super(ctx, 'subprocess') + } + + /** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. + * @returns the live process handle (streams/readers, signalling, outcome promise). + */ + abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle +} + +export default SubprocessService diff --git a/packages/subprocess/subprocess/src/invariant.ts b/packages/subprocess/subprocess/src/invariant.ts new file mode 100644 index 0000000000..62a720babd --- /dev/null +++ b/packages/subprocess/subprocess/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess' + +/** Cordis companion plugin name. */ +export const name = 'subprocess-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this stateless seam owns spawn-spec/handle types, while implementations own observations. */ +const install: InvariantInstaller = () => {} + +/** + * Register the subprocess invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts new file mode 100644 index 0000000000..fdfc44b3c2 --- /dev/null +++ b/packages/subprocess/subprocess/src/types.ts @@ -0,0 +1,194 @@ +/** + * Vocabulary for the subprocess seam: fully-specified spawn requests with + * Node-shaped per-stream stdio modes, bounded collected output with spill + * recovery, raw piped streams, and tree-scoped termination. Command + * defaulting, shell semantics, protocol framing, and presentation belong to + * consumers such as the bash executor seam. + * @module dsh-subprocess/types + */ + +import type { Readable, Writable } from 'node:stream' + +/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ +export const DSH_ENV_PREFIX = 'DSH_' as const + +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` + +/** Trusted DeepSeek Harness variables for one child-process execution. */ +export type DshEnvironment = Readonly> + +/** One captured stream: the (possibly truncated) text plus recovery info. */ +export interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} + +/** + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +export type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } + +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +export interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} + +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect + +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +export interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} + +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). + */ +export interface SubprocessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ + graceMs: number + /** + * Abort signal — starts the terminate escalation on the process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. + */ + env?: Record | undefined +} + +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. + */ +export interface SubprocessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null +} + +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +export interface SubprocessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} + +/** + * Cursor-free incremental access to one collected output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). + */ +export interface SubprocessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): SubprocessOutputRead +} + +/** Offset-based readers for the streams spawned in collect mode. */ +export interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} + +/** + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. + */ +export interface SubprocessHandle { + /** Process id (tree root); -1 when the spawn itself failed. */ + readonly pid: number + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + readonly done: Promise + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise +} diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts new file mode 100644 index 0000000000..46cddaba63 --- /dev/null +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' + +/** + * Minimal concrete service: a hand-built handle. The seam is spawn-only — + * defaulting, shell semantics, and deadlines belong to callers — so this stub + * is all an implementation owes the abstract class. + */ +class StubSubprocessService extends SubprocessService { + spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false } + const collected = spec.stdio.stdout !== 'pipe' && spec.stdio.stdout !== 'inherit' + ? { stdout: { readFrom: () => read } } + : {} + return { + pid: spec.argv.length, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected, + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate: () => {}, + waitForExit: () => Promise.resolve(true), + } + } +} + +describe('SubprocessService seam', () => { + it('a concrete subclass registers as ctx.subprocess and serves the abstract API', async () => { + const ctx = new Context() + await ctx.plugin(StubSubprocessService) + const handle = ctx.subprocess.spawn({ + argv: ['true'], + cwd: '/stub', + stdio: { stdin: 'ignore', stdout: { maxBytes: 1 }, stderr: 'inherit' }, + graceMs: 1, + }) + expect(handle.pid).toBe(1) + expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome.exitCode).toBe(0) + }) + + it('loading a second implementation throws (one subprocess service per context — cordis standard)', async () => { + const ctx = new Context() + await ctx.plugin(StubSubprocessService) + class SecondService extends StubSubprocessService {} + await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/) + }) + + it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => { + process.env.DSH_SCRUB_PROBE = 'stale' + process.env.SCRUB_PROBE_TOKEN = 'secret' + process.env.SCRUB_PROBE_PLAIN = 'visible' + try { + const env = scrubbedParentEnv() + expect(env.DSH_SCRUB_PROBE).toBeUndefined() + expect(env.SCRUB_PROBE_TOKEN).toBeUndefined() + expect(env.SCRUB_PROBE_PLAIN).toBe('visible') + expect(env.PATH).toBeDefined() + } finally { + delete process.env.DSH_SCRUB_PROBE + delete process.env.SCRUB_PROBE_TOKEN + delete process.env.SCRUB_PROBE_PLAIN + } + }) +}) diff --git a/packages/subagent/subagent-subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json similarity index 68% rename from packages/subagent/subagent-subprocess/tsconfig.json rename to packages/subprocess/subprocess/tsconfig.json index d970a00263..9966c8ca8a 100644 --- a/packages/subagent/subagent-subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -8,6 +8,12 @@ "src" ], "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, { "path": "../../support/invariants" } diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index baabe0d898..8d82f4b7d4 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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 -README.md: d35872e5bb06be88dc5999bfa1800083b2fbbf3c -README.zh.md: a706b6db5408538c578cb2a1cfc3aa99804a930d +README.md: d22e6e2d95a1ed930a7f4876daf4b06e2f761f7a +README.zh.md: 514b7ebfe02cb34ed559633a0fd82cf6194fa4b3 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index d35872e5bb..d22e6e2d95 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -57,7 +57,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index a706b6db54..514b7ebfe0 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -57,7 +57,7 @@ defineAcpSnapshotSuite({ 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index cd84f42513..ff98e620d3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' -import { setTimeout as delay } from 'node:timers/promises' +import { vi } from 'vitest' import { ClientSideConnection, PROTOCOL_VERSION, @@ -457,17 +457,25 @@ async function waitForPersistedTurnStart( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn?: number, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + let invalidRecord: { error: unknown } | undefined + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - const openTurn = log === undefined ? undefined : latestOpenTurn(log.content) - if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return - if (Date.now() >= deadline) { + let openTurn: number | undefined + try { + openTurn = log === undefined ? undefined : latestOpenTurn(log.content) + } catch (error) { + // A malformed persisted record is a scenario bug, not a not-yet state: + // vi.waitFor retries every callback throw, so capture the validation + // failure, resolve the wait, and rethrow immediately below. + invalidRecord = { error } + return + } + if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) { const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}` throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) + if (invalidRecord !== undefined) throw invalidRecord.error } /** @@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd( sessionId: string, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - if (log !== undefined && latestTurnIsClosed(log.content)) return - if (Date.now() >= deadline) { + if (log === undefined || !latestTurnIsClosed(log.content)) { throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Wait for a cwd-relative marker proving an external action reached readiness. */ @@ -499,13 +504,11 @@ async function waitForWorkspaceFile( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { const target = join(cwd, path) - const deadline = Date.now() + timeoutMs - while (!existsSync(target)) { - if (Date.now() >= deadline) { + await vi.waitFor(() => { + if (!existsSync(target)) { throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Return whether the last complete raw-JSONL turn boundary closes its turn. */ diff --git a/packages/support/llm-mock-server/src/cli.ts b/packages/support/llm-mock-server/src/cli.ts index 786a74c0f4..1787f318ca 100644 --- a/packages/support/llm-mock-server/src/cli.ts +++ b/packages/support/llm-mock-server/src/cli.ts @@ -3,6 +3,7 @@ * @module @deepseek-ai/dsh-llm-mock-server/cli */ +import { parseArgs } from 'node:util' import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts' import type { ConcreteMockLlmBehavior, @@ -63,14 +64,6 @@ Other: --help ` -function optionValue(argv: readonly string[], index: number, option: string): string { - const value = argv[index + 1] - if (value === undefined || value.startsWith('--')) { - throw new Error(`dsh-llm-mock-server: ${option} requires a value`) - } - return value -} - function numberValue(option: string, value: string): number { const parsed = Number(value) if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`) @@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights { return weights } +/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */ +const CLI_OPTIONS = { + 'sequence': { type: 'string' }, + 'host': { type: 'string' }, + 'port': { type: 'string' }, + 'api-key': { type: 'string' }, + 'listen-delay-ms': { type: 'string' }, + 'repeat-last': { type: 'boolean' }, + 'seed': { type: 'string' }, + 'random-weights': { type: 'string' }, + 'success-text': { type: 'string' }, + 'partial-text': { type: 'string' }, + 'reasoning-text': { type: 'string' }, + 'chunk-size': { type: 'string' }, + 'chunk-delay-ms': { type: 'string' }, + 'disconnect-delay-ms': { type: 'string' }, + 'retry-after-ms': { type: 'string' }, + 'request-id': { type: 'string' }, + 'tool-name': { type: 'string' }, + 'tool-arguments': { type: 'string' }, +} as const + /** * Parse standalone server arguments without starting a process or listener. + * Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric + * coercion, bounds, and cross-option constraints remain manual below it. * @param argv - arguments after the executable name. * @returns help or validated run configuration. */ export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult { if (argv.includes('--help')) return { kind: 'help' } - let sequenceRaw: string | undefined - let host: string | undefined - let port = 8_000 - let apiKey: string | undefined - let listenDelayMs: number | undefined - let repeatLast = false - let randomSeed: number | undefined - let randomWeights: MockLlmRandomWeights | undefined - let successText: string | undefined - let partialText: string | undefined - let reasoningText: string | undefined - let chunkSize: number | undefined - let chunkDelayMs: number | undefined - let disconnectDelayMs: number | undefined - let retryAfterMs: number | undefined - let requestId: string | undefined - let toolName: string | undefined - let toolArguments: string | undefined + const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false }) - for (let index = 0; index < argv.length; index += 1) { - const option = argv[index] as string - if (option === '--repeat-last') { - repeatLast = true - continue - } - const value = optionValue(argv, index, option) - index += 1 - switch (option) { - case '--sequence': sequenceRaw = value; break - case '--host': host = value; break - case '--port': port = numberValue(option, value); break - case '--api-key': apiKey = value; break - case '--listen-delay-ms': - listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS) - break - case '--seed': randomSeed = numberValue(option, value); break - case '--random-weights': randomWeights = parseRandomWeights(value); break - case '--success-text': successText = value; break - case '--partial-text': partialText = value; break - case '--reasoning-text': reasoningText = value; break - case '--chunk-size': chunkSize = numberValue(option, value); break - case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break - case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break - case '--retry-after-ms': retryAfterMs = numberValue(option, value); break - case '--request-id': requestId = value; break - case '--tool-name': toolName = value; break - case '--tool-arguments': toolArguments = value; break - default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`) - } - } + const host = values.host + const port = values.port === undefined ? 8_000 : numberValue('--port', values.port) + const apiKey = values['api-key'] + const listenDelayMs = values['listen-delay-ms'] === undefined + ? undefined + : boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS) + const repeatLast = values['repeat-last'] ?? false + const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed) + const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights']) + const successText = values['success-text'] + const partialText = values['partial-text'] + const reasoningText = values['reasoning-text'] + const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size']) + const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms']) + const disconnectDelayMs = values['disconnect-delay-ms'] === undefined + ? undefined + : numberValue('--disconnect-delay-ms', values['disconnect-delay-ms']) + const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms']) + const requestId = values['request-id'] + const toolName = values['tool-name'] + const toolArguments = values['tool-arguments'] - if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + const sequenceRaw = values.sequence const parsedSequence = parseSequence(sequenceRaw) if (parsedSequence.startsUnavailable && port === 0) { throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port') diff --git a/packages/support/llm-mock-server/tests/cli.spec.ts b/packages/support/llm-mock-server/tests/cli.spec.ts index 12c5bd6926..04b221beda 100644 --- a/packages/support/llm-mock-server/tests/cli.spec.ts +++ b/packages/support/llm-mock-server/tests/cli.spec.ts @@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => { it.each([ [[], /--sequence is required/], - [['--wat'], /requires a value/], - [['--wat', 'x'], /unknown option/], + // Tokenizer-level failures carry node:util parseArgs's own messages. + [['--wat'], /Unknown option '--wat'/], + [['--wat', 'x'], /Unknown option '--wat'/], + [['--port'], /Option '--port ' argument missing/], + [['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/], [['--port', 'NaN', '--sequence', 'success'], /finite number/], [['--sequence', 'success,'], /non-empty/], [['--sequence', 'success,connection_refused'], /only as the first/], @@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => { [['--sequence', 'unknown'], /unknown behavior/], [['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/], [['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/], - [['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/], + // `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe. + [['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/], [['--sequence', 'success', '--seed', '1'], /require random/], diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 193079ed81..904379994d 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -11,7 +11,7 @@ import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm' /** @@ -426,12 +426,20 @@ class ReplayAdapter extends LlmAdapter { }))) } - override resolveModelContext(provider: string, model: string): Promise { + override resolveModel(provider: string, model: string): Promise { const configured = this.providers.get(provider) /* v8 ignore next -- LlmService only asks about routes registered from this same map. */ - if (configured === undefined) return Promise.resolve(undefined) - const contextWindow = configured.models?.find(candidate => candidate.id === model)?.contextWindow - return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + if (configured === undefined) return Promise.resolve({ provider, id: model, name: model }) + const configuredModel = configured.models?.find(candidate => candidate.id === model) + return Promise.resolve({ + provider, + id: model, + name: configuredModel?.name ?? model, + ...configuredModel?.description === undefined ? {} : { description: configuredModel.description }, + ...configuredModel?.contextWindow === undefined + ? {} + : { context: { contextWindow: configuredModel.contextWindow } }, + }) } override stream(options: GenerateOptions): AsyncIterable { diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 09ae63dc91..85a0e2d719 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -338,10 +338,12 @@ describe('installLlmReplay (through the real LlmService)', () => { { provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' }, ]) await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) - await expect(ctx.llm.resolveModelContext('deepseek', 'flash')).resolves.toEqual({ contextWindow: 128_000 }) - await expect(ctx.llm.resolveModelContext('deepseek', 'pro')).resolves.toBeUndefined() - await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted')).resolves.toBeUndefined() - await expect(ctx.llm.resolveModelContext('empty', 'unlisted')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({ + context: { contextWindow: 128_000 }, + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') + await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context') + await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context') expect(await drain(ctx.llm.stream({ provider: 'deepseek', model: 'pro', messages: [] }))).toEqual(TEXT_CHUNKS) dispose() diff --git a/packages/support/loader-smoke/README.i18n.yaml b/packages/support/loader-smoke/README.i18n.yaml index a4e0016620..4794ac72cb 100644 --- a/packages/support/loader-smoke/README.i18n.yaml +++ b/packages/support/loader-smoke/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 -README.md: 8e53550608037a3c9a272db825933b7224ab24db -README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637 +README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685 +README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 8e53550608..73610ce50e 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. -- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it. +- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget. - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup. diff --git a/packages/support/loader-smoke/README.zh.md b/packages/support/loader-smoke/README.zh.md index 5310429ab5..17f8481220 100644 --- a/packages/support/loader-smoke/README.zh.md +++ b/packages/support/loader-smoke/README.zh.md @@ -19,5 +19,5 @@ ## 已知限制与待完成工作 - **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。 -- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。 +- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。 - **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。 diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 570ee2c5ca..ebdd62373a 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "execa": "^10.0.0", "tsx": "^4.22.4" }, "peerDependencies": { diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 61ad3b9d16..e573684a4e 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -11,10 +11,10 @@ * @module @deepseek-ai/dsh-loader-smoke */ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 @@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise((resolve, reject) => { - const child = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - let deferredFailure: Error | undefined - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`) - child.kill('SIGKILL') - }, processTimeoutMs) - - child.once('exit', (code) => { - clearTimeout(timer) - if (deferredFailure !== undefined) { - reject(deferredFailure) - } else if (code === 0) { - resolve({ stdout, stderr }) - } else { - reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - } - }) - - // process.execPath and a just-created pipe make these OS-error paths - // impractical to induce without replacing the boundary under test. - /* v8 ignore start */ - child.once('error', (error) => { - clearTimeout(timer) - reject(new Error(`${options.label} failed to start: ${error.message}`)) - }) - child.stdin.once('error', (error) => { - deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`) - child.kill('SIGKILL') - }) - /* v8 ignore stop */ - - child.stdin.end() + // `input: ''` writes nothing and closes stdin — the fixture-visible + // stdin-close contract. `reject: false` folds spawn errors, the SIGKILL + // deadline, and nonzero exits into independent result fields, so the + // diagnostics below embed both streams on every failure. + const result = await execa(launch.command, launch.args, { + cwd, + env: launch.env, + input: '', + timeout: processTimeoutMs, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } await options.inspect?.(cwd) - return result + return { stdout: result.stdout, stderr: result.stderr } } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index a941c774cf..66d516740c 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/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 -README.md: db6bbf6970c73767c8e9df1148f98d23047d19b7 -README.zh.md: 6a9b817cb5af2c43b8e66100333e46665359eba8 +README.md: 3615f68953cfe6cdc0e6fc41bf75b8dfdf90a310 +README.zh.md: c4a7d829cc1583b65d2c8afa1683d957f68722e9 diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index db6bbf6970..3615f68953 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -16,11 +16,11 @@ The list belongs to the ONE agent session that called the tool. There is no suba ## Validation -Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content` and more than one `in_progress` task (a coherent plan has at most one task active). Ordering and the discipline of keeping the list current are left to the model via the tool description. +Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, more than one `in_progress` task (a coherent plan has at most one task active), and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. Ordering and the discipline of keeping the list current are left to the model via the tool description. ## Rendering -The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves; the [TUI app](../../examples/tui-demo) shows it as a persistent plan. +The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) shows it as a persistent plan, and the [web client](../../client/ui-conversation) renders a plan strip plus a dedicated tool row off `ConversationSnapshot.todos` ([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)). ## Export shape diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 6a9b817cb5..c4a7d829cc 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -16,11 +16,11 @@ ## 验证 -除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务)。顺序与保持列表最新的纪律由模型根据工具描述负责。 +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。顺序与保持列表最新的纪律由模型根据工具描述负责。 ## 渲染 -规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表;[TUI 应用](../../examples/tui-demo)将其显示为持久计划。 +规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 应用](../../examples/tui-demo)将其显示为持久计划,[web 客户端](../../client/ui-conversation)则基于 `ConversationSnapshot.todos` 渲染计划横条与专属工具行([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md))。 ## 导出形状 diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 1da9914ac9..66b0a8ab12 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -29,7 +29,10 @@ const DESCRIPTION = /** * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link * TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry - * has already enforced the status enum; the cast below records that guarantee. + * has already enforced the status enum and rejected unknown item keys (`additionalProperties: + * false` — the logged snapshot must equal what the model believes it wrote, so a nested/extended + * item shape fails loud at the schema boundary instead of silently flattening); the cast below + * records that guarantee. */ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { const todos: TodoItem[] = [] @@ -66,7 +69,7 @@ export function apply(ctx: Context): void { description: 'The COMPLETE task list, replacing any previous list.', items: { type: 'object', - additionalProperties: true, + additionalProperties: false, properties: { content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' }, status: { diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 79758cb3ea..2883cfdc02 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -126,6 +126,7 @@ describe('dsh-tool-todo', () => { { label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' }, { label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' }, { label: 'two in_progress', todos: [{ content: 'a', status: 'in_progress' }, { content: 'b', status: 'in_progress' }], fragment: 'in_progress' }, + { label: 'unknown item keys', todos: [{ content: 'a', status: 'pending', children: [] }], fragment: 'not a declared property' }, ])('rejects $label as an isError result', async ({ todos, fragment }) => { const ctx = await setup() const result = await callTodo(ctx, { todos }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index a2a17d022d..a11958da85 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -1,6 +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 -README.md: 3d64de9f0703838cad10f8e04665ec4b985d00dc -README.zh.md: 5cf41dd76c7c28cd2d605466c7c10cfe1c1dd958 +# pnpm run verify-translation-pairing --write packages/ui/tui/README.md +README.md: 84ed3de78469ab778118ee5599acfd8476b0ecd1 +README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 3d64de9f07..84ed3de784 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -24,13 +24,13 @@ When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. -`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. `/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name. -The footer sums the session's reported usage as `↑`, followed by `cache %` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow. +The footer sums the session's reported usage as `↑`, followed by `cache %` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow. -`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. +`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. `/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. @@ -49,7 +49,7 @@ The footer sums the session's reported usage as `↑ | `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | -| `modelDialogWidth` | `72` | Model-selector width in columns | +| `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | @@ -116,7 +116,7 @@ The fixed instruction is part of the stable system-prompt prefix and is reusable #### What the model sees -The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing. +The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model route in prompt variables and the selected provider/model/reasoning-effort target in request routing. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 5cf41dd76c..771b89a910 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新的 `todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型和 reasoning 状态。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。 +TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新的 `todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -24,13 +24,13 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览;Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 -`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Enter 选择,Escape 关闭。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一组值;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 +`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 `/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill: [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。 -Footer 将会话报告的用量汇总为 `↑`;任何输入计费后,后面会显示 `cache %`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。 +Footer 将会话报告的用量汇总为 `↑`;任何输入计费后,后面会显示 `cache %`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。 -`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 +`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 `/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose(资源释放)并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 @@ -49,7 +49,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | | `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 | | `questionDialogMaxHeight` | `20` | 问题面板最大行数 | -| `modelDialogWidth` | `72` | 模型选择器宽度(列数) | +| `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | | `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 | | `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 | @@ -116,7 +116,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量和请求路由中同时收到所选提供方/模型对。 +`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量中收到所选提供方/模型路由,并在请求路由中收到所选提供方/模型/推理强度目标。 #### Token 影响 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index de0ef02f7e..a88abb5586 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -31,6 +31,7 @@ import { type EditorTheme, type Focusable, type MarkdownTheme, + type SelectItem, type SelectListTheme, type SlashCommand, type Terminal, @@ -53,6 +54,8 @@ import { assertNever, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmModelInfo, + LlmModelReasoningInfo, + ReasoningEffortId, StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' @@ -233,7 +236,7 @@ const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const modelDialogWidthSchema = z.number().step(1).min(20).default(72) +const modelDialogWidthSchema = z.number().step(1).min(20).default(76) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) @@ -356,7 +359,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf maxResumeOptions: config?.maxResumeOptions ?? 8, questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, - modelDialogWidth: config?.modelDialogWidth ?? 72, + modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, @@ -580,15 +583,39 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning' interface ModelChoice extends AgentLlmTarget { modelName: string description?: string + reasoning?: LlmModelReasoningInfo +} + +interface ModelDialogSelection { + choice: ModelChoice + reasoningEffort: ReasoningEffortId | undefined } function targetLabel(target: AgentLlmTarget): string { return `${target.provider}/${target.model}` } +function compactTargetLabel(target: AgentLlmTarget): string { + return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` +} + +function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { + if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' + return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort +} + function initialTarget(agent: Agent): AgentLlmTarget | undefined { const logged = agent.session.requestHeader()?.config - if (logged !== undefined) return { provider: logged.provider, model: logged.model } + if (logged !== undefined) { + if (logged.reasoningEffort === undefined) { + return { provider: logged.provider, model: logged.model } + } + return { + provider: logged.provider, + model: logged.model, + reasoningEffort: logged.reasoningEffort, + } + } if (agent.options.provider === undefined || agent.options.model === undefined) return undefined return { provider: agent.options.provider, model: agent.options.model } } @@ -607,11 +634,15 @@ async function readModelChoices( ) { models.push({ provider: provider.id, id: current.model, name: current.model }) } - return models.map((model): ModelChoice => ({ - provider: provider.id, - model: model.id, - modelName: model.name, - ...model.description === undefined ? {} : { description: model.description }, + return Promise.all(models.map(async (model): Promise => { + const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning + return { + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + ...reasoning === undefined ? {} : { reasoning }, + } })) })) return groups.flat() @@ -1226,24 +1257,40 @@ function renderDialog( class ModelDialog implements Component { private readonly list: SelectList + private readonly items: Map + private readonly choices: Map + private readonly efforts: Map + private readonly currentValue: string | undefined constructor( choices: readonly ModelChoice[], current: AgentLlmTarget | undefined, maxVisible: number, private readonly palette: Palette, - done: (choice: ModelChoice) => void, + done: (selection: ModelDialogSelection) => void, cancel: () => void, ) { - this.list = new SelectList(choices.map(choice => ({ - value: targetLabel(choice), - label: displayText(targetLabel(choice)), - description: [ - displayText(choice.modelName), - ...choice.description === undefined ? [] : [displayText(choice.description)], - ...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [], - ].join(' — '), - })), maxVisible, dialogSelectTheme(palette)) + this.items = new Map() + this.choices = new Map() + this.efforts = new Map() + this.currentValue = current === undefined ? undefined : targetLabel(current) + for (const choice of choices) { + const value = targetLabel(choice) + const isCurrent = current?.provider === choice.provider && current.model === choice.model + this.choices.set(value, choice) + this.efforts.set( + value, + isCurrent + ? current.reasoningEffort ?? choice.reasoning?.defaultEffort + : choice.reasoning?.defaultEffort, + ) + this.items.set(value, { + value, + label: displayText(value), + description: this.describeChoice(choice, isCurrent), + }) + } + this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette)) const currentIndex = current === undefined ? 0 : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) @@ -1252,17 +1299,58 @@ class ModelDialog implements Component { const selected = choices.find(choice => targetLabel(choice) === item.value) /* v8 ignore next -- SelectList only returns values built from `choices`. */ if (selected === undefined) return - done(selected) + done({ + choice: selected, + reasoningEffort: this.efforts.get(item.value), + }) } this.list.onCancel = cancel } + private describeChoice(choice: ModelChoice, isCurrent: boolean): string { + const selectedEffort = this.efforts.get(targetLabel(choice)) + const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort) + const effortLabel = selectedEffort === undefined + ? choice.reasoning === undefined ? undefined : 'provider default' + : effort?.name ?? selectedEffort + return [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...effortLabel === undefined ? [] : [displayText(effortLabel)], + ...isCurrent ? ['current'] : [], + ].join(' — ') + } + + private cycleReasoningEffort(): void { + const selectedItem = this.list.getSelectedItem() + /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ + if (selectedItem === null) return + const choice = this.choices.get(selectedItem.value) + if (choice?.reasoning === undefined) return + const current = this.efforts.get(selectedItem.value) + const efforts: Array = [ + ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], + ...choice.reasoning.efforts.map(effort => effort.id), + ] + const currentIndex = efforts.indexOf(current) + const next = efforts[(currentIndex + 1) % efforts.length] + this.efforts.set(selectedItem.value, next) + const item = this.items.get(selectedItem.value) + /* v8 ignore next -- items and choices are constructed from the same values. */ + if (item === undefined) return + item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) + } + invalidate(): void { this.list.invalidate() } handleInput(data: string): void { - this.list.handleInput(data) + if (matchesKey(data, Key.shift(Key.tab))) { + this.cycleReasoningEffort() + } else { + this.list.handleInput(data) + } this.invalidate() } @@ -1271,7 +1359,7 @@ class ModelDialog implements Component { return renderDialog('Select model', [ ...this.list.render(innerWidth), '', - this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'), + this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), ], width, this.palette) } } @@ -1938,7 +2026,7 @@ export function createTuiChat( () => sessionTitle ?? config.welcome, palette, resolved.color && resolved.truecolor, - () => target.current?.model, + () => target.current === undefined ? undefined : compactTargetLabel(target.current), ) const footer = new FooterComponent( agent, @@ -1946,7 +2034,7 @@ export function createTuiChat( () => toolsExpanded, () => tokens, runtime.formatCwd, - () => target.current?.model, + () => target.current === undefined ? undefined : compactTargetLabel(target.current), () => contextWindow === undefined ? undefined : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), @@ -2019,8 +2107,8 @@ export function createTuiChat( contextWindow = undefined const resolution = selected === undefined ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) - : ctx.llm.resolveModelContext(selected.provider, selected.model).then( - context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const), + : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( + info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const), (error: unknown) => ({ kind: 'error', error } as const), ) contextResolution = resolution @@ -2036,14 +2124,31 @@ export function createTuiChat( } resolveContextWindow(target.current) - const selectModel = (selected: ModelChoice): void => { - if (target.current?.provider === selected.provider && target.current.model === selected.model) { - appendNotice(`Model is already ${targetLabel(selected)}.`) + const selectModel = ( + selected: ModelChoice, + explicitReasoning?: { effort: ReasoningEffortId | undefined }, + ): void => { + const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model + const reasoningEffort = explicitReasoning === undefined + ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) + : explicitReasoning.effort + if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { + const reasoning = targetReasoningLabel(selected, reasoningEffort) + appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) return } - target.current = { provider: selected.provider, model: selected.model } + target.current = { + provider: selected.provider, + model: selected.model, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + } resolveContextWindow(target.current) - appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`) + const reasoning = targetReasoningLabel(selected, reasoningEffort) + appendNotice([ + `Model selected: ${targetLabel(selected)}.`, + ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], + 'New steps will use it.', + ].join(' ')) } const showModelSelector = (choices: readonly ModelChoice[]): void => { @@ -2059,9 +2164,9 @@ export function createTuiChat( target.current, resolved.maxModelOptions, palette, - (selected) => { + (selection) => { void session.close() - selectModel(selected) + selectModel(selection.choice, { effort: selection.reasoningEffort }) }, () => { void session.close() }, ), @@ -2633,12 +2738,17 @@ export function createTuiChat( const steps = events.filter(event => event.type === 'step/start').length const toolCalls = events.filter(event => event.type === 'tool/call').length const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current)) + const effort = target.current === undefined + ? 'unset' + : target.current.reasoningEffort === undefined + ? 'default' + : displayText(target.current.reasoningEffort) const groups: readonly (readonly StatusCardRow[])[] = [ [ ['Session', displayText(agent.session.id)], ['Title', displayText(sessionTitle ?? 'untitled')], ['Directory', displayText(cwd)], - ['Model', `${model} ${palette.dim(`(reasoning ${showReasoning ? 'shown' : 'hidden'})`)}`], + ['Model', `${model} ${palette.dim(`(effort ${effort}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'})`)}`], ], [ ['Agent', [ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 37eee99735..c74c469133 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -8,7 +8,12 @@ import AgentRegistry, { type AgentStatus, type SendOptions, } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, + LlmModelInfo, + LlmProviderInfo, + LlmResolvedModelInfo, +} from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -47,7 +52,10 @@ export interface TuiHarnessOptions { providers: LlmProviderInfo[] models: LlmModelInfo[] listModels?: (provider: string) => Promise - resolveModelContext?: (provider: string, model: string) => Promise + resolveModelInfo?: ( + provider: string, + model: string, + ) => Promise> } /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */ sessionPersistence?: { @@ -118,9 +126,20 @@ export async function createTuiTestHarness model.provider === provider).map(model => ({ ...model }))) }, - resolveModelContext(provider: string, model: string) { - return catalog.resolveModelContext?.(provider, model) - ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 }) + async resolveModelInfo(provider: string, model: string) { + const advertised = catalog.models.find(candidate => + candidate.provider === provider && candidate.id === model) + const capabilities = await (catalog.resolveModelInfo?.(provider, model) + ?? Promise.resolve({ + context: { contextWindow: options.contextWindow ?? 128_000 }, + })) + return { + provider, + id: model, + name: advertised?.name ?? model, + ...advertised?.description === undefined ? {} : { description: advertised.description }, + ...capabilities, + } }, } as never) } diff --git a/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt new file mode 100644 index 0000000000..cef660ff49 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt @@ -0,0 +1,42 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=92 viewportRow=15 bufferRow=15 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +4| " " + style 1-1 inverse +5| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 65-91 dim +7-12| +13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " + style 8-83 fg=bright-blue +14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " + style 8-8 fg=bright-blue + style 38-77 fg=bright-black + style 83-83 fg=bright-blue +15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " + style 8-8 fg=bright-blue + style 10-77 fg=bright-blue inverse + style 83-83 fg=bright-blue +16| " │ │ " + style 8-8 fg=bright-blue + style 83-83 fg=bright-blue +17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ " + style 8-8 fg=bright-blue + style 10-71 dim + style 83-83 fg=bright-blue +18| " ╰──────────────────────────────────────────────────────────────────────────╯ " + style 8-83 fg=bright-blue +19-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt index f10c99d03a..c86b1f1f3d 100644 --- a/packages/ui/tui/tests/snapshots/model-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -20,23 +20,23 @@ buffer style 0-43 dim style 65-91 dim 7-12| -13| " ╭ Select model ────────────────────────────────────────────────────────╮ " - style 10-81 fg=bright-blue -14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " - style 10-10 fg=bright-blue - style 12-72 fg=bright-blue inverse - style 81-81 fg=bright-blue -15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ " - style 10-10 fg=bright-blue - style 38-60 fg=bright-black - style 81-81 fg=bright-blue -16| " │ │ " - style 10-10 fg=bright-blue - style 81-81 fg=bright-blue -17| " │ ↑/↓ navigate • Enter select • Esc cancel │ " - style 10-10 fg=bright-blue - style 12-51 dim - style 81-81 fg=bright-blue -18| " ╰──────────────────────────────────────────────────────────────────────╯ " - style 10-81 fg=bright-blue +13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " + style 8-83 fg=bright-blue +14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " + style 8-8 fg=bright-blue + style 10-77 fg=bright-blue inverse + style 83-83 fg=bright-blue +15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " + style 8-8 fg=bright-blue + style 36-77 fg=bright-black + style 83-83 fg=bright-blue +16| " │ │ " + style 8-8 fg=bright-blue + style 83-83 fg=bright-blue +17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ " + style 8-8 fg=bright-blue + style 10-71 dim + style 83-83 fg=bright-blue +18| " ╰──────────────────────────────────────────────────────────────────────────╯ " + style 8-83 fg=bright-blue 19-31| diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt index 5800901f08..4e711e37c9 100644 --- a/packages/ui/tui/tests/snapshots/model-switching.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=6 bufferRow=6 +cursor hidden column=1 viewportRow=7 bufferRow=7 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -11,15 +11,17 @@ buffer 2| " deepseek-v4-pro • main-session" style 1-32 dim 3| -4| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. " - style 1-64 fg=bright-black -5| "────────────────────────────────────────────────────────────────────────────────────────────" +4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps " + style 1-91 fg=bright-black +5| " will use it. " + style 1-12 fg=bright-black +6| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -6| " " +7| " " style 1-1 inverse -7| "────────────────────────────────────────────────────────────────────────────────────────────" +8| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -8| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed" +9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-41 dim style 65-91 dim -9-31| +10-31| diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt index de46d59543..4937592cc7 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -41,13 +41,13 @@ buffer style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -16| "│ Model: deepseek/deepseek-v4-pro (reasoning │" +16| "│ Model: deepseek/deepseek-v4-pro (effort │" style 0-0 dim style 3-12 fg=bright-black style 40-55 dim -17| "│ shown) │" +17| "│ default; reasoning blocks shown) │" style 0-0 dim - style 15-20 dim + style 15-46 dim style 55-55 dim 18| "│ │" style 0-0 dim diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index 73d98953f6..915d4e58ef 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -25,68 +25,68 @@ buffer style 1-9 fg=bright-magenta bold 10| " Session inspected. " 11| -12| "╭─ Session status ─────────────────────────────────────────────────╮" +12| "╭─ Session status ───────────────────────────────────────────────────────────────╮" style 0-2 dim style 3-16 fg=bright-blue bold - style 17-67 dim -13| "│ Session: main-session │" + style 17-81 dim +13| "│ Session: main-session │" style 0-0 dim style 3-12 fg=bright-black - style 67-67 dim -14| "│ Title: Inspect session diagnostics │" + style 81-81 dim +14| "│ Title: Inspect session diagnostics │" style 0-0 dim style 3-12 fg=bright-black - style 67-67 dim -15| "│ Directory: /workspace/project │" + style 81-81 dim +15| "│ Directory: /workspace/project │" style 0-0 dim style 3-12 fg=bright-black - style 67-67 dim -16| "│ Model: deepseek/deepseek-v4-pro (reasoning shown) │" + style 81-81 dim +16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │" style 0-0 dim style 3-12 fg=bright-black - style 40-56 dim - style 67-67 dim -17| "│ │" + style 40-79 dim + style 81-81 dim +17| "│ │" style 0-0 dim - style 67-67 dim -18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" + style 81-81 dim +18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" style 0-0 dim style 3-12 fg=bright-black - style 67-67 dim -19| "│ │" + style 81-81 dim +19| "│ │" style 0-0 dim - style 67-67 dim -20| "│ Tokens: 1,250 input + 340 output │" + style 81-81 dim +20| "│ Tokens: 1,250 input + 340 output │" style 0-0 dim style 3-12 fg=bright-black - style 67-67 dim -21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" + style 81-81 dim +21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-26 fg=bright-blue style 27-32 dim - style 67-67 dim -22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" + style 81-81 dim +22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-20 fg=bright-blue style 21-32 dim - style 67-67 dim -23| "│ │" + style 81-81 dim +23| "│ │" style 0-0 dim - style 67-67 dim -24| "│ Created: 2026-07-22 09:10:11 UTC │" + style 81-81 dim +24| "│ Created: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black - style 67-67 dim -25| "│ Active: 2026-07-22 09:10:11 UTC │" + style 81-81 dim +25| "│ Active: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black - style 67-67 dim -26| "╰──────────────────────────────────────────────────────────────────╯" - style 0-67 dim + style 81-81 dim +26| "╰────────────────────────────────────────────────────────────────────────────────╯" + style 0-81 dim 27| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim 28| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 1dc6ffa7cd..a507db017a 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -45,6 +45,7 @@ const CHECKPOINTS = [ 'surface-after-compaction-narrow', 'surface-after-compaction-wide', 'model-selector', + 'model-effort-switching', 'model-switching', 'errors-and-help', 'disposed-terminal', @@ -228,33 +229,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7 describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { const harness = await setupSnapshot() - await renderAfter(harness, () => { - harness.agent.status = 'running' - harness.ctx.emit('agent/status', harness.agent, 'running') - appendUser(harness.session, 'Show the live update.') - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, + // Freeze the loader's first animation interval so this semantic snapshot + // cannot select a different spinner frame under scheduler contention. + const frozenLoaderTimer = setInterval(() => {}, 60_000) + const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer) + try { + await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') + appendUser(harness.session, 'Show the live update.') + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 1, blockType: 'text' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, + }) }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 1, blockType: 'text' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, - }) - }) - await checkpoint('conversation-streaming', harness.terminal) - await disposeSnapshot(harness) + const loaderIntervalMs = intervals.mock.calls[0]?.[1] + if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval') + await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5)) + await checkpoint('conversation-streaming', harness.terminal) + } finally { + intervals.mockRestore() + clearInterval(frozenLoaderTimer) + await disposeSnapshot(harness) + } }) it('pins failed-stream retraction, scheduled retry, and eventual success', async () => { @@ -632,8 +645,29 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.dispose() }) - it('pins the model selector and selection notice', async () => { - const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) + it('pins the model selector, effort cycling, and provider-default selection', async () => { + const harness = await setupSnapshot({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [ + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' }, + ], + resolveModelInfo: (_provider, model) => Promise.resolve({ + context: { contextWindow: 128_000 }, + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + ...model === 'deepseek-v4-flash' + ? { defaultEffort: ReasoningEffortId('high') } + : {}, + }, + }), + }, + }, { columns: 92, rows: 32 }) await renderAfter(harness, () => { harness.terminal.send('/model') harness.terminal.send('\r') @@ -641,6 +675,13 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('model-selector', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { harness.terminal.send('\x1b[B') + harness.terminal.send('\x1b[Z') + harness.terminal.send('\x1b[Z') + harness.terminal.send('\x1b[Z') + harness.terminal.send('\x1b[Z') + }) + await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true }) + await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c7af95061a..b59054659f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' -import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { + ReasoningEffortId, + type LlmCallConfig, + type LlmModelReasoningInfo, +} from '@deepseek-ai/dsh-llm' import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' @@ -143,7 +147,11 @@ function provideLlmCatalog(ctx: Context): void { ctx.provide('llm', { listProviders: () => [], listModels: () => Promise.resolve([]), - resolveModelContext: () => Promise.resolve(undefined), + resolveModelInfo: (provider: string, model: string) => Promise.resolve({ + provider, + id: model, + name: model, + }), } as never) } @@ -157,7 +165,7 @@ describe('TUI config', () => { maxResumeOptions: 8, questionDialogWidth: 200, questionDialogMaxHeight: 20, - modelDialogWidth: 72, + modelDialogWidth: 76, modelDialogMaxHeight: 20, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, @@ -1157,7 +1165,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') expect(result.terminal.output).toContain('↑1.3k ↓42') - // Context resolution is async (resolveModelContext); settle before reading. + // Exact model resolution is async; settle before reading. await tick() expect(result.terminal.output).toContain('42% context tools:collapsed') // Narrow terminals clip the right-hand context/tools segment first; the @@ -1714,7 +1722,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('main-session') expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07') expect(result.terminal.output).toContain('/workspace/status') - expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (reasoning hidden)') + expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks') + expect(result.terminal.output).toContain('hidden)') expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls') expect(result.terminal.output).toContain('1,250 input + 340 output') expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)') @@ -1742,7 +1751,7 @@ describe('pi-tui chat lifecycle and transcript', () => { catalog: { providers: [], models: [], - resolveModelContext: () => Promise.resolve(undefined), + resolveModelInfo: () => Promise.resolve({}), }, }) result.terminal.send('/status') @@ -1750,7 +1759,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('untitled') - expect(result.terminal.output).toContain('unset (reasoning shown)') + expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)') expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls') expect(result.terminal.output).toContain('n/a (0 read + 0 write)') expect(result.terminal.output).toContain('7 used · capacity unknown') @@ -2289,6 +2298,7 @@ describe('pi-tui chat lifecycle and transcript', () => { it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => { const initialContext = Promise.withResolvers<{ contextWindow: number }>() + let deferInitialContext = true const result = await setup({ agentOptions: { provider: 'alpha', model: 'a1' }, contextTokens: 50, @@ -2300,9 +2310,42 @@ describe('pi-tui chat lifecycle and transcript', () => { { provider: 'beta', id: 'b1', name: 'Beta One' }, { provider: 'beta', id: 'shared', name: 'Beta Shared' }, ], - resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1' - ? initialContext.promise - : Promise.resolve({ contextWindow: 200 }), + async resolveModelInfo(provider, model) { + const shouldDeferContext = provider === 'alpha' && model === 'a1' && deferInitialContext + if (shouldDeferContext) deferInitialContext = false + const context = shouldDeferContext + ? await initialContext.promise + : { contextWindow: 200 } + let reasoning: LlmModelReasoningInfo | undefined + if (model === 'a1') { + reasoning = { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('low'), + } + } else if (model === 'b1') { + reasoning = { + efforts: [ + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + } + } else if (provider === 'alpha' && model === 'shared') { + reasoning = { + efforts: [ + { id: ReasoningEffortId('standard'), name: 'Standard' }, + { id: ReasoningEffortId('ultra'), name: 'Ultra' }, + ], + } + } + return { + context, + ...reasoning === undefined ? {} : { reasoning }, + } + }, }, }) @@ -2327,6 +2370,92 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\x1b') await tick() + const providerDefaultOutput = result.terminal.output.length + result.terminal.send('/model alpha/shared') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: provider default.') + }) + result.terminal.send('/model') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Select model') + }) + expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — provider default') + result.terminal.send('\x1b[Z') + await tick() + expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Standard') + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: Standard.') + + const resetDefaultOutput = result.terminal.output.length + result.terminal.send('/model') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Standard — current') + }) + result.terminal.send('\x1b[Z') + await tick() + expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Ultra — current') + result.terminal.send('\x1b[Z') + await tick() + expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — provider default') + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: provider default.') + const explicitResetSeed: LlmCallConfig = { + provider: 'beta', + model: 'b1', + reasoningEffort: ReasoningEffortId('max'), + } + await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + await expect(agentEvents(result.ctx, result.agent).waterfall( + 'agent/request', + 0, + 0, + explicitResetSeed, + new AbortController().signal, + () => Promise.resolve(explicitResetSeed), + )).resolves.toEqual({ provider: 'alpha', model: 'shared' }) + + const nonReasoningOutput = result.terminal.output.length + result.terminal.send('/model') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Select model') + }) + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[Z') + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model selected: beta/shared.') + result.terminal.send('/model beta/shared') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model is already beta/shared.') + }) + await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + result.terminal.send('/model alpha/a1') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Reasoning effort: Low.') + }) + const inheritedEffort: LlmCallConfig = { + provider: 'alpha', + model: 'a1', + reasoningEffort: ReasoningEffortId('max'), + } + await expect(agentEvents(result.ctx, result.agent).waterfall( + 'agent/request', + 0, + 0, + inheritedEffort, + new AbortController().signal, + () => Promise.resolve(inheritedEffort), + )).resolves.toEqual({ provider: 'beta', model: 'shared' }) + result.agent.status = 'running' const runningSelectorOutput = result.terminal.output.length result.terminal.send('/model') @@ -2335,13 +2464,18 @@ describe('pi-tui chat lifecycle and transcript', () => { const output = result.terminal.output.slice(runningSelectorOutput) expect(output).toContain('Select model') expect(output).toContain('alpha/a1') - expect(output).toContain('Alpha One — Fast — current') + expect(output).toContain('Alpha One — Fast — Low — current') + expect(output).toContain('Beta One — High') }) result.terminal.send('\x1b[B') result.terminal.send('\x1b[B') + result.terminal.send('\x1b[Z') + await tick() + expect(result.terminal.output).toContain('Beta One — Max') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Model selected: beta/b1') + expect(result.terminal.output).toContain('Reasoning effort: Max.') expect(result.agent.sent).toEqual([]) expect(result.agent.steered).toEqual([]) initialContext.resolve({ contextWindow: 100 }) @@ -2360,8 +2494,12 @@ describe('pi-tui chat lifecycle and transcript', () => { result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') await tick() - expect(result.terminal.output).toContain('b1 ') + expect(result.terminal.output).toContain('b1 max ') expect(result.terminal.output).toContain('25% context tools:collapsed') + result.terminal.send('/status') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('beta/b1 (effort max; reasoning blocks shown)') const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) @@ -2369,7 +2507,12 @@ describe('pi-tui chat lifecycle and transcript', () => { const request = await agentEvents(result.ctx, result.agent).waterfall( 'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed), ) - expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + expect(request).toEqual({ + provider: 'beta', + model: 'b1', + reasoningEffort: ReasoningEffortId('max'), + temperature: 0.2, + }) await dispose(result) }) @@ -2379,7 +2522,13 @@ describe('pi-tui chat lifecycle and transcript', () => { catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] }, beforeMount(session) { session.append('request/header', { - header: { config: { provider: 'beta', model: 'private' } }, + header: { + config: { + provider: 'beta', + model: 'private', + reasoningEffort: ReasoningEffortId('ultra'), + }, + }, reason: 'initial', }) }, @@ -2389,15 +2538,36 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(resumed.terminal.output).toContain('Select model') expect(resumed.terminal.output).toContain('beta/private') - expect(resumed.terminal.output).toContain('private — current') + expect(resumed.terminal.output).toContain('private — ultra — current') + resumed.terminal.send('\x1b') + await tick() + resumed.terminal.send('/model beta/private') + resumed.terminal.send('\r') + await tick() + expect(resumed.terminal.output).toContain('with reasoning effort ultra') await dispose(resumed) + const resumedDefault = await setup({ + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }], + models: [{ provider: 'alpha', id: 'default', name: 'Default Model' }], + }, + beforeMount(session) { + session.append('request/header', { + header: { config: { provider: 'alpha', model: 'default' } }, + reason: 'initial', + }) + }, + }) + expect(resumedDefault.terminal.output).toContain('default • main-session') + await dispose(resumedDefault) + const unset = await setup({ agentOptions: {}, catalog: { providers: [{ id: 'alpha', name: 'Alpha' }], models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], - resolveModelContext: () => Promise.resolve(undefined), + resolveModelInfo: () => Promise.resolve({}), }, }) unset.terminal.send('/model') @@ -2429,7 +2599,7 @@ describe('pi-tui chat lifecycle and transcript', () => { providers: [{ id: 'deepseek', name: 'DeepSeek' }], models: [], listModels: () => Promise.reject(new Error('catalog offline')), - resolveModelContext: () => Promise.reject(new Error('capacity offline')), + resolveModelInfo: () => Promise.reject(new Error('capacity offline')), }, }) failed.terminal.send('/model') @@ -2439,6 +2609,20 @@ describe('pi-tui chat lifecycle and transcript', () => { }) expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline') await dispose(failed) + + const reasoningFailed = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }], + resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')), + }, + }) + reasoningFailed.terminal.send('/model') + reasoningFailed.terminal.send('\r') + await vi.waitFor(() => { + expect(reasoningFailed.terminal.output).toContain('Could not read the model catalog: reasoning metadata offline') + }) + await dispose(reasoningFailed) }) it('does not render a model catalog that resolves after TUI disposal', async () => { @@ -2480,7 +2664,7 @@ describe('pi-tui chat lifecycle and transcript', () => { catalog: { providers: [{ id: 'deepseek', name: 'DeepSeek' }], models: [], - resolveModelContext: () => context.promise, + resolveModelInfo: () => context.promise.then(value => ({ context: value })), }, }) await contextResult.controller.dispose() diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index eb3fa4731d..58641384b9 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/README.i18n.yaml @@ -1,6 +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 -README.md: 5e567115c386d14b7e412ed2502e7290826a5e5e -README.zh.md: b17fe4107908381806d4029481bbf03696c4f313 +# pnpm run verify-translation-pairing --write packages/web/tool-web/README.md +README.md: 9b78920b1b6c611118294421dec1e75e381ed5d6 +README.zh.md: 2152c40f1ccac2272fa0b2681514a712417c0ad3 diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 5e567115c3..9b78920b1b 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -11,7 +11,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_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. | +| `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-timeout-policy`), not a model argument. | Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state. @@ -26,8 +26,9 @@ The normalized seam results are also the canonical tool values: `WebSearchResult | `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). | | `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-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument. +`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/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 @@ -126,6 +127,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **`htmlToMarkdown` is a minimal regex converter, not an HTML parser** — it strips script/style/noscript, keeps headings/bullets/links, and decodes about a dozen named entities; tables, images, and nested formatting are lost. +- **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 ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). - **The model-facing surface 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 b17fe41079..2152c40f1c 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -11,7 +11,7 @@ | 工具 | 参数 | 行为 | |---|---|---| | `web_search` | `query`(string) | 发现。返回可选答案与源 URL。`max_results` **不** 面向模型:工具设置上限(`searchMaxResults` 配置,默认 8)并传给 seam。 | -| `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为近似 markdown 的文本;文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-timeout-policy`),不是模型参数。 | +| `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为 markdown(turndown,带 GFM 表格/删除线);文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-timeout-policy`),不是模型参数。 | 两个工具都选择并发调度,因为提供方读取会返回内容,不会修改父 agent 状态。 @@ -26,8 +26,9 @@ | `searchMaxResults` | `8` | 一次 `web_search` 调用返回的源数量上限(seam 截断更长的提供方列表并标记)。 | | `fetchTimeoutMs` | `30000` | `web_fetch` 的协作式工具调用超时预算(ms)。 | | `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 | +| `fetchMaxOutputChars` | `200000` | 同步转换的源字符数与单次完整 `web_fetch` 输出的上限(状态头、渲染后的主体与页脚合并计算);主体被截断时,在能容纳的情况下附带截断提示。 | -`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。 +`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。 ```yaml - id: tool-web @@ -126,6 +127,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex ## 已知限制与暂缓事项 -- **`htmlToMarkdown` 是最小正则转换器,不是 HTML parser**:它会移除 script/style/noscript,保留标题/项目符号/链接,并解码约十余个命名 entity;表格、图片与嵌套格式会丢失。 +- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([决策记录](../../../.agents/notes/implemented/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/domain 授权。 diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index ec1d33f4d4..9e1a54b6cd 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -35,10 +35,13 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@joplin/turndown-plugin-gfm": "^1.0.67", + "schemastery": "^3.18.0", + "turndown": "^7.2.4" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@types/turndown": "^5.0.6", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index cc2ae52970..75108f663c 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -6,12 +6,75 @@ */ import type { Context } from 'cordis' +import TurndownService from 'turndown' +import { gfm } from '@joplin/turndown-plugin-gfm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' -import { htmlToMarkdown } from './html.ts' + +/** + * The shared HTML→markdown converter: turndown over its bundled domino DOM, + * with GitHub-flavored tables/strikethrough (`@joplin/turndown-plugin-gfm`). + * The style options are fixed model-facing presentation (matching the repo's + * markdown conventions), not deployment tunables. `remove` drops non-content + * elements wholesale — turndown's default keeps their text. The instance is + * stateless across `turndown()` calls and safe to share. + */ +const turndown = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + bulletListMarker: '-', +}) +turndown.use(gfm) +turndown.remove(['script', 'style', 'noscript']) + +/** Render one GFM table cell without interpreting HTML span counts. */ +function renderTableCell(content: string, index: number): string { + const prefix = index === 0 ? '| ' : ' ' + const escaped = content.trim().replace(/\n\r/g, '
    ').replace(/\n/g, '
    ').replace(/\|+/g, '\\|').padEnd(3, ' ') + return `${prefix}${escaped} |` +} + +/** Whether a row is the table's Markdown heading row. */ +function isTableHeadingRow(row: HTMLTableRowElement): boolean { + const cells = Array.from(row.cells) + const section = row.parentElement as HTMLTableSectionElement + const table = section.parentElement as HTMLTableElement + return (section.nodeName === 'THEAD' || table.rows[0] === row) + && cells.every(cell => cell.nodeName === 'TH') +} + +/** Map an HTML table-cell alignment to the GFM separator marker. */ +function tableBorder(cell: HTMLTableCellElement): string { + const alignment = (cell.getAttribute('align') || cell.style.textAlign || '').toLowerCase() + if (alignment === 'left') return ':---' + if (alignment === 'right') return '---:' + if (alignment === 'center') return ':---:' + return '---' +} + +turndown.addRule('tableCellWithoutSpanExpansion', { + filter: ['th', 'td'], + replacement(content, node) { + const cell = node as HTMLTableCellElement + const row = cell.parentNode as HTMLTableRowElement + // GFM cannot represent spanning cells. Ignoring colspan keeps conversion + // work and output proportional to the source instead of the numeric attribute. + return renderTableCell(content, Array.prototype.indexOf.call(row.childNodes, cell)) + }, +}) +turndown.addRule('tableRowWithoutSpanExpansion', { + filter: 'tr', + replacement(content, node) { + const row = node as HTMLTableRowElement + const border = isTableHeadingRow(row) + ? Array.from(row.cells, (cell, index) => renderTableCell(tableBorder(cell), index)).join('') + : '' + return `\n${content}${border.length > 0 ? `\n${border}` : ''}` + }, +}) /** * Validate value constraints the schema DSL can't express: a non-blank `url`. @@ -27,36 +90,182 @@ export function parseFetchArgs(args: { url: string }): { url: string } { return { url: args.url } } +/** + * Nesting-depth ceiling above which HTML skips conversion and passes through + * raw. Conversion runs synchronously on the event loop, and unclosed-tag + * nesting makes domino's tree (and turndown's walk over it) superlinear — + * measured: depth 512 ≈ 0.15s, 2,000 ≈ 2s, 20,000 ≈ 5s — during which the + * cooperative `fetchTimeoutMs` timer cannot fire. Real pages nest a few dozen + * levels; 512 is far above content and far below weaponizable. A robustness + * invariant, not a tunable. + */ +const MAX_CONVERSION_DEPTH = 512 + +/** Elements that never take a closing tag, so they do not grow the lexical stack. */ +const VOID_ELEMENTS = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'param', 'source', 'track', 'wbr', +]) + +/** Elements whose contents HTML parses as text until their matching end tag. */ +const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'noscript']) + +/** Whether a character can occur after a raw-text end-tag name. */ +function isTagBoundary(char: string | undefined): boolean { + return char === undefined || char === '>' || char === '/' || /\s/.test(char) +} + +/** Find the matching raw-text end tag without interpreting markup-like body text. */ +function findRawTextEnd(lowerHtml: string, name: string, from: number): number { + const prefix = `` characters, and only accepts a closing + * tag for the current element; malformed input therefore over-counts rather + * than hiding nesting. + * + * @param html - the decoded HTML body. + * @returns whether the body crosses {@link MAX_CONVERSION_DEPTH}. + */ +function exceedsConversionDepth(html: string): boolean { + const lowerHtml = html.toLowerCase() + const openElements: string[] = [] + let offset = 0 + let inComment = false + + while (offset < html.length) { + const start = html.indexOf('<', offset) + if (inComment) { + const end = html.indexOf('-->', offset) + if (end !== -1 && (start === -1 || end < start)) { + inComment = false + offset = end + 3 + continue + } + } + if (start === -1) break + if (!inComment && html.startsWith('/g, '') - - // Convert links to markdown before stripping tags. - text = text.replace(/]*\bhref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_match, href: string, label: string) => { - const cleanLabel = label.replace(/<[^>]+>/g, '').trim() - return cleanLabel.length > 0 ? `[${cleanLabel}](${href})` : href - }) - - // Headings → markdown hashes. - text = text.replace(/]*>([\s\S]*?)<\/h\1>/gi, (_match, level: string, body: string) => { - const hashes = '#'.repeat(Number(level)) - return `\n\n${hashes} ${body.replace(/<[^>]+>/g, '').trim()}\n\n` - }) - - // List items → bullets. - text = text.replace(/]*>([\s\S]*?)<\/li>/gi, (_match, body: string) => `\n- ${body.replace(/<[^>]+>/g, '').trim()}`) - - // Block-level breaks become paragraph breaks. - text = text - .replace(/<\/(p|div|section|article|header|footer|tr|table|ul|ol|blockquote)>/gi, '\n\n') - .replace(//gi, '\n') - - // Drop all remaining tags, decode entities, collapse whitespace. - text = text.replace(/<[^>]+>/g, '') - text = decodeEntities(text) - text = text - .replace(/[ \t\f\v]+/g, ' ') - .replace(/ *\n */g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() - return text -} diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 7096371ed1..74585b3cab 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -13,8 +13,7 @@ import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' -export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts' -export { htmlToMarkdown } from './html.ts' +export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall } from './fetch.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-web' @@ -25,7 +24,14 @@ export const inject = ['tools', 'web', 'systemPrompt'] /** Default cooperative tool-call timeout budget (ms) for the web tools. */ export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000 -/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */ +/** + * Default cap on one `web_fetch` output and on source characters converted + * synchronously. This leaves headroom above the local provider's default + * 100,000-character body cap while bounding custom providers and rendered output. + */ +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. */ export interface Config { /** Register `web_search`. Defaults to true. */ search?: boolean @@ -37,6 +43,8 @@ export interface Config { fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ searchTimeoutMs?: number + /** Cap on source characters converted and complete `web_fetch` output characters. Defaults to 200000. */ + fetchMaxOutputChars?: number } export const Config: z = z.object({ @@ -45,12 +53,13 @@ export const Config: z = z.object({ searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS), 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), }) /** The shape after schemastery applies its defaults to every field. */ type ResolvedConfig = Required -/** The result cap must be a positive integer (it bounds a provider's source list). */ +/** Configured count, timeout, and character caps must be positive integers. */ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { throw new Error(`tool-web: ${name} must be a positive integer`) @@ -72,6 +81,7 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('searchMaxResults', resolved.searchMaxResults) assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs) assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs) + assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars) if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs) - if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs) + if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars) } diff --git a/packages/web/tool-web/src/turndown-plugin-gfm.d.ts b/packages/web/tool-web/src/turndown-plugin-gfm.d.ts new file mode 100644 index 0000000000..66c9d929e4 --- /dev/null +++ b/packages/web/tool-web/src/turndown-plugin-gfm.d.ts @@ -0,0 +1,12 @@ +/** + * Ambient module declaration for `@joplin/turndown-plugin-gfm`, which ships no + * types and has no DefinitelyTyped package. Only the composite `gfm` plugin is + * declared; the package's individual plugins (`tables`, `strikethrough`, …) + * stay undeclared until something imports them. + */ +declare module '@joplin/turndown-plugin-gfm' { + import type TurndownService from 'turndown' + + /** The composite GitHub-flavored-markdown plugin (tables, strikethrough, task lists, highlighted code blocks). */ + export const gfm: TurndownService.Plugin +} diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 093184c4a7..2324922046 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import TurndownService from 'turndown' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -13,8 +14,6 @@ import { parseFetchArgs, presentSearchCall, presentFetchCall, - renderBody, - htmlToMarkdown, WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' @@ -82,17 +81,29 @@ describe('search formatting', () => { expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' }) }) + it('falls back to the raw URL as a source label when the URL is unparseable', () => { + const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] }) + 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' }) }) }) describe('fetch formatting', () => { + const NO_CAP = 1_000_000 + const HEADER = 'Fetched https://a.test (HTTP 200)\n\n' + const renderHtml = (content: string) => formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content }, + }, NO_CAP).slice(HEADER.length) + it('renders an html body to markdown text with a status header', () => { const out = formatFetchOutput({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'html', content: '

    Title

    Body text

    ' }, - }) + }, NO_CAP) expect(out).toContain('Fetched https://a.test (HTTP 200)') expect(out).toContain('# Title') expect(out).toContain('Body text') @@ -102,14 +113,140 @@ describe('fetch formatting', () => { const out = formatFetchOutput({ url: 'https://a.test', statusCode: 200, truncated: true, body: { kind: 'text', content: 'plain' }, - }) + }, NO_CAP) expect(out).toContain('plain') expect(out).toContain('Content truncated') }) - it('renderBody dispatches on kind', () => { - expect(renderBody({ kind: 'text', content: 'x' })).toBe('x') - expect(renderBody({ kind: 'html', content: '

    y

    ' })).toBe('y') + it('caps the complete output and notes truncation, even when markdown escaping expands the body', () => { + // 1,000 underscores render as 2,000 escaped characters — conversion can + // outgrow a provider-side body cap, so the bound applies to the output. + const out = formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: `

    ${'_'.repeat(1000)}

    ` }, + }, 500) + expect(out.length).toBeLessThanOrEqual(500) + expect(out).toContain('Fetched https://a.test (HTTP 200)') + expect(out).toContain('\\_\\_') + expect(out).toContain('Content truncated') + // Exact and tiny caps: the complete result is bounded, header and footer included. + const exact = formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'text', content: 'abc' }, + }, 'Fetched https://a.test (HTTP 200)\n\nabc'.length) + expect(exact).toBe('Fetched https://a.test (HTTP 200)\n\nabc') + const tiny = formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: true, + body: { kind: 'text', content: 'abcdef' }, + }, 10) + expect(tiny.length).toBeLessThanOrEqual(10) + expect(tiny).toBe('Fetched ht') + }) + + it('dispatches text and html bodies', () => { + expect(formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'text', content: 'x' }, + }, NO_CAP)).toBe(`${HEADER}x`) + expect(renderHtml('

    y

    ')).toBe('y') + }) + + it('converts html via turndown: entities, links, tables, nesting; drops script/style/noscript', () => { + expect(renderHtml('

    Tom & Jerry © Résumé

    link')) + .toBe('Tom & Jerry © Résumé\n\n[link](https://a.test)') + expect(renderHtml('

    Heading

    • one
    • two
    ')) + .toBe('## Heading\n\n- one\n- two') + expect(renderHtml('
    AB
    12
    ')) + .toBe('| A | B |\n| --- | --- |\n| 1 | 2 |') + expect(renderHtml('
    LRC
    123
    ')) + .toBe('| L | R | C |\n| :--- | ---: | :---: |\n| 1 | 2 | 3 |') + expect(renderHtml('

    bold italic

    quoted

    ')) + .toBe('**bold _italic_**\n\n> quoted') + }) + + it('does not expand numeric colspan attributes into unbounded output', () => { + const table = '
    A
    B
    ' + expect(renderHtml(table)).toBe('| A |\n| --- |\n| B |') + }) + + it('passes deeply nested html through raw without attempting conversion', () => { + // Unclosed-tag nesting makes the synchronous conversion superlinear + // (seconds at 20k levels, during which the cooperative timeout cannot + // fire), so the depth preflight skips conversion entirely; this must + // return fast, not merely not-throw. + const depth = 20_000 + const pathological = '
    '.repeat(depth) + 'x' + '
    '.repeat(depth) + const started = Date.now() + expect(formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: pathological }, + }, NO_CAP)).toBe(`${HEADER}${pathological}`) + expect(Date.now() - started).toBeLessThan(2_000) + }) + + it('comments and mismatched closing tags cannot hide deep nesting from the preflight', () => { + const pathological = '
    '.repeat(600) + 'x' + expect(formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: pathological }, + }, NO_CAP)).toBe(`${HEADER}${pathological}`) + const abruptlyClosedComments = '
    '.repeat(600) + 'x' + expect(formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: abruptlyClosedComments }, + }, NO_CAP)).toBe(`${HEADER}${abruptlyClosedComments}`) + }) + + it('the preflight accepts ordinary closed, void, self-closing, quoted, and raw-text markup', () => { + const paragraphs = '

    \'>x

    '.repeat(600) + const script = `` + expect(renderHtml(`<1bad>${paragraphs}${script}`)) + .not.toContain('x

    See today’s specials.