feat(subagent): add Claude Code provider
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/README.md
|
||||
README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5
|
||||
README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd
|
||||
README.md: 75d90616e25f74761ac4f78c429a2e0c1aabfb38
|
||||
README.zh.md: f22a5ecb4fc5d1324a5abe5afb16f34044d5312e
|
||||
|
||||
@@ -12,11 +12,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
|
||||
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) |
|
||||
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) |
|
||||
| `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) |
|
||||
| `subagent-claude-code/` | Out-of-process backend: the official Claude Agent SDK with one real Claude Code CLI query | (registers on `ctx.subagents`) |
|
||||
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) |
|
||||
|
||||
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. 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` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures.
|
||||
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. 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` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures.
|
||||
|
||||
The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
|
||||
@@ -12,11 +12,12 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age
|
||||
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents`) |
|
||||
| `subagent-claude-code/` | 进程外后端:使用官方 Claude Agent SDK 与一次真实 Claude Code CLI query | (注册到 `ctx.subagents`) |
|
||||
| `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) |
|
||||
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) |
|
||||
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) |
|
||||
| `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) |
|
||||
|
||||
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。
|
||||
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。
|
||||
|
||||
设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
6
packages/subagent/subagent-claude-code/README.i18n.yaml
Normal file
6
packages/subagent/subagent-claude-code/README.i18n.yaml
Normal file
@@ -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 packages/subagent/subagent-claude-code/README.md
|
||||
README.md: facaae300eeb8907076182a129aa216863dec8ec
|
||||
README.zh.md: 75627cb54032edde07ec1ba5e21a058768515e29
|
||||
96
packages/subagent/subagent-claude-code/README.md
Normal file
96
packages/subagent/subagent-claude-code/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# @deepseek-ai/dsh-subagent-claude-code
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
|
||||
|
||||
## Start and ownership
|
||||
|
||||
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`.
|
||||
|
||||
The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; this version produces neither `max-tokens` nor `refusal`.
|
||||
|
||||
Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate.
|
||||
|
||||
## Native settings and interaction
|
||||
|
||||
The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state.
|
||||
|
||||
Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own.
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
|
||||
| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. |
|
||||
|
||||
Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
|
||||
|
||||
Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default.
|
||||
|
||||
```yaml
|
||||
- id: subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-subagent-claude-code'
|
||||
config:
|
||||
env:
|
||||
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
## Product compatibility and evidence
|
||||
|
||||
The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. The real-product spec drives that official SDK and CLI against a loopback Anthropic Messages SSE service with a non-empty fake key. It proves the exact task, authentication, byte-exact answer, inherited host-setting marker, process failure, cancellation, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration alongside the Codex provider with neither product command available, verifies both fixed foreground-only tools, and records zero child starts.
|
||||
|
||||
The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Child request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The child pays for an independent Claude Code context and query. Child tokens do not enter the parent's context.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of the parent request cache. Reuse depends only on Claude Code's own model, instructions, tools, native settings, and fresh query.
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-tool-subagent`, the parent sees only the strict final Claude Code answer or the consumer's exact error for a non-completed result. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only: the new tool result follows the reusable parent request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
|
||||
- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode.
|
||||
- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow.
|
||||
- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending.
|
||||
- **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local.
|
||||
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
|
||||
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.
|
||||
96
packages/subagent/subagent-claude-code/README.zh.md
Normal file
96
packages/subagent/subagent-claude-code/README.zh.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# @deepseek-ai/dsh-subagent-claude-code
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。
|
||||
|
||||
## 启动与所有权
|
||||
|
||||
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。
|
||||
|
||||
SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;本版本不会产生 `max-tokens` 或 `refusal`。
|
||||
|
||||
本地取消会在结果竞态中胜出并映射为 `aborted`。`dispose()` 具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。
|
||||
|
||||
## 原生设置与交互
|
||||
|
||||
提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。
|
||||
|
||||
每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
|
||||
| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 |
|
||||
|
||||
生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。
|
||||
|
||||
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。
|
||||
|
||||
```yaml
|
||||
- id: subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-subagent-claude-code'
|
||||
config:
|
||||
env:
|
||||
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
## 产品兼容性与证据
|
||||
|
||||
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。
|
||||
|
||||
项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 子任务请求
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。
|
||||
|
||||
#### 对 token 的影响
|
||||
|
||||
子任务需为独立的 Claude Code 上下文和 query 承担 token 开销。子任务 token 不会进入父级上下文。
|
||||
|
||||
#### 对 KV Cache 的影响
|
||||
|
||||
这与父请求缓存相互独立。能否复用只取决于 Claude Code 自身的模型、指令、工具、原生设置和全新 query。
|
||||
|
||||
### 父级工具结果(间接)
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
通过 `dsh-tool-subagent`,父级模型只会看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。
|
||||
|
||||
#### 对 token 的影响
|
||||
|
||||
父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。
|
||||
|
||||
#### 对 KV Cache 的影响
|
||||
|
||||
仅追加:新的工具结果接在可复用的父请求前缀之后。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。
|
||||
- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。
|
||||
- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。
|
||||
- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。
|
||||
- **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
|
||||
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
|
||||
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。
|
||||
53
packages/subagent/subagent-claude-code/package.json
Normal file
53
packages/subagent/subagent-claude-code/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-claude-code",
|
||||
"description": "One-shot Claude Code subagent provider over the official Agent SDK",
|
||||
"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",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.93.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
95
packages/subagent/subagent-claude-code/src/index.ts
Normal file
95
packages/subagent/subagent-claude-code/src/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Fixed Claude Code one-shot subagent provider. Every accepted run invokes
|
||||
* the official Agent SDK in the delegating Session's workspace and places
|
||||
* the SDK-spawned real CLI under the shared subprocess owner.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-claude-code
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
assertPositiveFinite,
|
||||
NO_START_CAPABILITIES,
|
||||
resolveChildCwd,
|
||||
type ResolvedSubagentStartRequest,
|
||||
type SubagentCapabilities,
|
||||
type SubagentProvider,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
DEFAULT_DISPOSE_GRACE_MS,
|
||||
startClaudeCodeRun,
|
||||
type ClaudeCodeRunSpec,
|
||||
} from './run.ts'
|
||||
|
||||
export const name = 'subagent-claude-code'
|
||||
export const inject = ['subagents', 'subprocess']
|
||||
|
||||
/* jscpd:ignore-start -- sibling product providers intentionally expose the
|
||||
* same two deployment-owned fields without adding a shared config owner. */
|
||||
/** Deployment-owned environment and process-release bound. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Explicit environment entries layered over the subprocess seam's
|
||||
* credential-scrubbed parent environment.
|
||||
*/
|
||||
env?: Record<string, string>
|
||||
/** Grace in milliseconds for Claude Code process-tree termination. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
env: z.dict(z.string()).default({}),
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
})
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror
|
||||
* the Codex sibling; each product's lifecycle remains package-private. */
|
||||
class ClaudeCodeProvider implements SubagentProvider {
|
||||
readonly name = 'claude-code'
|
||||
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {}
|
||||
|
||||
start(request: ResolvedSubagentStartRequest) {
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: resolveChildCwd(
|
||||
'subagent-claude-code',
|
||||
undefined,
|
||||
request.parent.session.header.cwd,
|
||||
),
|
||||
env: this.config.env,
|
||||
disposeGraceMs: this.config.disposeGraceMs,
|
||||
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
|
||||
onError: (error, stopReason) => {
|
||||
this.ctx.logger.warn(
|
||||
`subagent-claude-code: child run failed (${stopReason}): ${error.message}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
return startClaudeCodeRun(request, spec)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the fixed `claude-code` provider.
|
||||
* @param ctx - context carrying shared subagent and subprocess services.
|
||||
* @param config - explicit child environment and disposal grace.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite(
|
||||
'subagent-claude-code',
|
||||
'disposeGraceMs',
|
||||
resolved.disposeGraceMs,
|
||||
)
|
||||
ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved))
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
31
packages/subagent/subagent-claude-code/src/invariant.ts
Normal file
31
packages/subagent/subagent-claude-code/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for
|
||||
* `@deepseek-ai/dsh-subagent-claude-code`.
|
||||
* @module @deepseek-ai/dsh-subagent-claude-code/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-claude-code-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: lifecycle pairing belongs to the shared subagent
|
||||
* service and process-tree ownership belongs to the subprocess service.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - plugin context carrying the invariant registry.
|
||||
* @returns the installed registration's disposer.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
159
packages/subagent/subagent-claude-code/src/process.ts
Normal file
159
packages/subagent/subagent-claude-code/src/process.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Projection from the shared managed-process handle to the official Claude
|
||||
* Agent SDK's custom-spawn process interface.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-claude-code/process
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type {
|
||||
SpawnedProcess,
|
||||
SpawnOptions,
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
function thrown(value: unknown): Error {
|
||||
/* v8 ignore next -- the subprocess seam rejects with Error. */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the SDK environment to the shared subprocess seam's defined-value
|
||||
* overlay without changing the effective child environment.
|
||||
* @param env - SDK-composed child environment.
|
||||
* @returns entries whose values survive Node's subprocess environment.
|
||||
*/
|
||||
export function definedEnvironment(
|
||||
env: SpawnOptions['env'],
|
||||
): Record<string, string> {
|
||||
const defined: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value !== undefined) defined[name] = value
|
||||
}
|
||||
return defined
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one official SDK spawn request to the shared process owner.
|
||||
* @param options - command, arguments, workspace, environment, and forwarded signal from the SDK.
|
||||
* @param graceMs - process-tree termination grace.
|
||||
* @returns the fully explicit shared subprocess request.
|
||||
*/
|
||||
export function claudeSpawnSpec(
|
||||
options: SpawnOptions,
|
||||
graceMs: number,
|
||||
): SubprocessSpawnSpec {
|
||||
if (options.cwd === undefined || options.cwd.length === 0) {
|
||||
throw new Error('subagent-claude-code: SDK spawn request omitted its workspace')
|
||||
}
|
||||
return {
|
||||
argv: [options.command, ...options.args],
|
||||
cwd: options.cwd,
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
||||
graceMs,
|
||||
signal: options.signal,
|
||||
env: definedEnvironment(options.env),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK-facing view of one shared managed process. Protocol transport remains
|
||||
* in the official SDK; this adapter only projects streams and exit events.
|
||||
*/
|
||||
export class ManagedClaudeCodeProcess implements SpawnedProcess {
|
||||
readonly stdin
|
||||
readonly stdout
|
||||
private readonly events = new EventEmitter()
|
||||
private exitCodeValue: number | null = null
|
||||
private signalCodeValue: NodeJS.Signals | null = null
|
||||
private killRequested = false
|
||||
|
||||
/**
|
||||
* Project a managed process with piped stdin and stdout.
|
||||
* @param child - shared handle that remains the process-tree authority.
|
||||
*/
|
||||
constructor(private readonly child: SubprocessHandle) {
|
||||
if (child.stdin === undefined || child.stdout === undefined) {
|
||||
throw new Error('subagent-claude-code: SDK child requires piped stdin and stdout')
|
||||
}
|
||||
this.stdin = child.stdin
|
||||
this.stdout = child.stdout
|
||||
// EventEmitter gives `error` special throw semantics without a listener.
|
||||
// The SDK attaches its listener synchronously after custom spawn returns,
|
||||
// while this no-op also contains an already-rejected spawn handle.
|
||||
this.events.on('error', () => {})
|
||||
void child.done.then(
|
||||
(outcome) => {
|
||||
this.exitCodeValue = outcome.exitCode
|
||||
this.signalCodeValue = outcome.signal
|
||||
this.events.emit('exit', outcome.exitCode, outcome.signal)
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.events.emit('error', thrown(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the SDK has requested managed tree termination. */
|
||||
get killed(): boolean {
|
||||
return this.killRequested
|
||||
}
|
||||
|
||||
/** Direct-child exit code, or null while running or after signal exit. */
|
||||
get exitCode(): number | null {
|
||||
return this.exitCodeValue
|
||||
}
|
||||
|
||||
/** Direct-child terminating signal, if any. */
|
||||
get signalCode(): NodeJS.Signals | null {
|
||||
return this.signalCodeValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the SDK's termination request to the tree-scoped process owner.
|
||||
* @param _signal - SDK-selected signal; the shared seam owns its escalation ladder.
|
||||
* @returns false only after exit or a previous termination request.
|
||||
*/
|
||||
kill(_signal: NodeJS.Signals): boolean {
|
||||
if (
|
||||
this.killRequested
|
||||
|| this.exitCodeValue !== null
|
||||
|| this.signalCodeValue !== null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.killRequested = true
|
||||
this.child.terminate()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Register a persistent process lifecycle listener. */
|
||||
on(
|
||||
event: 'exit' | 'error',
|
||||
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
|
||||
| ((error: Error) => void),
|
||||
): void {
|
||||
this.events.on(event, listener)
|
||||
}
|
||||
|
||||
/** Register a one-shot process lifecycle listener. */
|
||||
once(
|
||||
event: 'exit' | 'error',
|
||||
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
|
||||
| ((error: Error) => void),
|
||||
): void {
|
||||
this.events.once(event, listener)
|
||||
}
|
||||
|
||||
/** Remove a process lifecycle listener. */
|
||||
off(
|
||||
event: 'exit' | 'error',
|
||||
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
|
||||
| ((error: Error) => void),
|
||||
): void {
|
||||
this.events.off(event, listener)
|
||||
}
|
||||
}
|
||||
357
packages/subagent/subagent-claude-code/src/run.ts
Normal file
357
packages/subagent/subagent-claude-code/src/run.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* One-shot Claude Code lifecycle: invoke the official Agent SDK, place its
|
||||
* real CLI process under the shared subprocess owner, map only strict SDK
|
||||
* success to completion, and dispose to whole-tree quiescence.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-claude-code/run
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
query as officialQuery,
|
||||
type Options,
|
||||
type Query,
|
||||
type SDKMessage,
|
||||
type SDKResultMessage,
|
||||
type SpawnOptions,
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
settleRunResult,
|
||||
subprocessRunHandle,
|
||||
type SubagentResult,
|
||||
type SubagentRun,
|
||||
type SubagentStartRequest,
|
||||
type SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
scrubbedParentEnv,
|
||||
type SubprocessHandle,
|
||||
type SubprocessSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import {
|
||||
claudeSpawnSpec,
|
||||
ManagedClaudeCodeProcess,
|
||||
} from './process.ts'
|
||||
|
||||
/** Default POSIX grace between subprocess termination tiers. */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/** Largest delay Node schedules without collapsing it to one millisecond. */
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647n
|
||||
|
||||
/**
|
||||
* Bound final exit observation at twice a positive finite grace without
|
||||
* narrowing the public config to Node's single-timer integer range.
|
||||
*/
|
||||
function doubledGraceWindow(graceMs: number): {
|
||||
readonly signal: AbortSignal
|
||||
readonly cancel: () => void
|
||||
} {
|
||||
const whole = Math.floor(graceMs)
|
||||
let remaining = BigInt(whole) * 2n
|
||||
+ BigInt(Math.ceil((graceMs - whole) * 2))
|
||||
const controller = new AbortController()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const arm = (): void => {
|
||||
const chunk = remaining > MAX_TIMER_DELAY_MS
|
||||
? MAX_TIMER_DELAY_MS
|
||||
: remaining
|
||||
remaining -= chunk
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
if (remaining === 0n) {
|
||||
controller.abort()
|
||||
} else {
|
||||
arm()
|
||||
}
|
||||
}, Number(chunk))
|
||||
}
|
||||
arm()
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cancel: () => {
|
||||
if (timer === undefined) return
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type QueryFactory = (params: {
|
||||
prompt: string
|
||||
options: Options
|
||||
}) => Query
|
||||
|
||||
/** Fully resolved inputs for one official Claude Agent SDK query. */
|
||||
export interface ClaudeCodeRunSpec {
|
||||
/** Parent Session workspace supplied to the SDK and real CLI. */
|
||||
readonly cwd: string
|
||||
/** Explicit deployment/test environment layered after shared scrubbing. */
|
||||
readonly env: Record<string, string>
|
||||
/** Subprocess termination grace and final tree-exit bound. */
|
||||
readonly disposeGraceMs: number
|
||||
/** Shared subprocess service spawn operation. */
|
||||
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
|
||||
/** Official query entrypoint; replaced only by package-local unit tests. */
|
||||
readonly query?: QueryFactory
|
||||
/** Diagnostic sink for a post-publication error flattened into a result. */
|
||||
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
|
||||
}
|
||||
|
||||
function thrown(value: unknown): Error {
|
||||
/* v8 ignore next -- SDK and subprocess failures reject with Error. */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and preserve the one-shot task before crossing the SDK boundary.
|
||||
* @param prompt - task content accepted from the shared subagent service.
|
||||
* @returns the exact text sequence as one SDK prompt.
|
||||
*/
|
||||
export function textTask(prompt: readonly ContentBlock[]): string {
|
||||
if (prompt.length === 0) {
|
||||
throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
|
||||
}
|
||||
const texts: string[] = []
|
||||
for (const block of prompt) {
|
||||
if (block.type !== 'text') {
|
||||
throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
|
||||
}
|
||||
texts.push(block.text)
|
||||
}
|
||||
if (texts.every(text => text.trim().length === 0)) {
|
||||
throw new Error('subagent-claude-code: the one-shot task must not be empty')
|
||||
}
|
||||
return texts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly derive the only SDK result that can complete a shared run.
|
||||
* @param message - an official discriminated result union.
|
||||
* @returns exact final text for a successful, non-error result.
|
||||
*/
|
||||
export function successfulResult(message: SDKResultMessage): string {
|
||||
if (
|
||||
message.subtype !== 'success'
|
||||
|| message.is_error
|
||||
|| message.result.trim().length === 0
|
||||
) {
|
||||
const detail = message.subtype === 'success'
|
||||
? 'success result was marked as an error or contained no answer'
|
||||
: message.errors.join('; ') || message.subtype
|
||||
throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`)
|
||||
}
|
||||
return message.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the complete SDK stream and require one strict success plus normal
|
||||
* iterator completion.
|
||||
* @param query - published official SDK query.
|
||||
* @param setOutput - captures the candidate result for error diagnostics.
|
||||
* @returns the completed shared result.
|
||||
*/
|
||||
export async function consumeClaudeQuery(
|
||||
query: AsyncIterable<SDKMessage>,
|
||||
setOutput: (output: ContentBlock[]) => void,
|
||||
): Promise<SubagentResult> {
|
||||
let answer: string | undefined
|
||||
for await (const message of query) {
|
||||
if (message.type !== 'result') continue
|
||||
answer = successfulResult(message)
|
||||
setOutput([{ type: 'text', text: answer }])
|
||||
}
|
||||
if (answer === undefined) {
|
||||
throw new Error('subagent-claude-code: Claude Code ended without a result')
|
||||
}
|
||||
return {
|
||||
output: [{ type: 'text', text: answer }],
|
||||
stopReason: 'completed',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the official query, terminate the managed process tree, and wait for
|
||||
* the subprocess owner to prove it is gone.
|
||||
* @param query - official SDK query, when creation reached that point.
|
||||
* @param child - shared-service handle that owns the CLI process tree.
|
||||
* @param graceMs - termination grace used to bound final exit observation.
|
||||
*/
|
||||
export async function disposeClaudeCodeChild(
|
||||
query: Pick<Query, 'close'> | undefined,
|
||||
child: SubprocessHandle,
|
||||
graceMs: number,
|
||||
): Promise<void> {
|
||||
const failures: Error[] = []
|
||||
let treeExited = child.pid <= 0
|
||||
try {
|
||||
query?.close()
|
||||
} catch (error: unknown) {
|
||||
failures.push(thrown(error))
|
||||
}
|
||||
|
||||
if (child.pid > 0) {
|
||||
child.terminate()
|
||||
const exitWindow = doubledGraceWindow(graceMs)
|
||||
try {
|
||||
treeExited = await child.waitForExit(exitWindow.signal)
|
||||
if (!treeExited) {
|
||||
failures.push(new Error(
|
||||
'subagent-claude-code: Claude Code process tree did not exit within its dispose window',
|
||||
))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failures.push(thrown(error))
|
||||
} finally {
|
||||
exitWindow.cancel()
|
||||
}
|
||||
}
|
||||
if (treeExited) {
|
||||
try {
|
||||
await child.done
|
||||
} catch (error: unknown) {
|
||||
failures.push(thrown(error))
|
||||
}
|
||||
} else {
|
||||
// The bounded tree observation owns teardown completion. Keep a later
|
||||
// direct-child spawn failure observed without turning that bound into an
|
||||
// unbounded wait.
|
||||
void child.done.catch(() => {})
|
||||
}
|
||||
|
||||
const firstFailure = failures[0]
|
||||
if (failures.length === 1 && firstFailure !== undefined) throw firstFailure
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
'subagent-claude-code: query and process cleanup failed',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed official SDK options for one one-shot provider run.
|
||||
* @param spec - workspace, environment, process seam, and disposal policy.
|
||||
* @param controller - per-run cancellation owner.
|
||||
* @param capture - receives the real managed child synchronously from the SDK hook.
|
||||
* @returns options that inherit native settings while disabling persistence and user questions.
|
||||
*/
|
||||
export function claudeQueryOptions(
|
||||
spec: ClaudeCodeRunSpec,
|
||||
controller: AbortController,
|
||||
capture: (child: SubprocessHandle) => void,
|
||||
): Options {
|
||||
return {
|
||||
abortController: controller,
|
||||
cwd: spec.cwd,
|
||||
env: { ...scrubbedParentEnv(), ...spec.env },
|
||||
persistSession: false,
|
||||
disallowedTools: ['AskUserQuestion'],
|
||||
spawnClaudeCodeProcess: (options: SpawnOptions) => {
|
||||
const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
|
||||
capture(child)
|
||||
return new ManagedClaudeCodeProcess(child)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one official Claude Agent SDK query and publish its one-shot run.
|
||||
* @param request - resolved shared subagent request.
|
||||
* @param spec - workspace, environment, process seam, and diagnostic policy.
|
||||
* @returns the published run after both Query and real CLI handle exist.
|
||||
*/
|
||||
export async function startClaudeCodeRun(
|
||||
request: SubagentStartRequest,
|
||||
spec: ClaudeCodeRunSpec,
|
||||
): Promise<SubagentRun> {
|
||||
const prompt = textTask(request.prompt)
|
||||
if (request.signal.aborted) {
|
||||
throw new Error('subagent-claude-code: request was aborted before SDK startup')
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const requestCancel = (): void => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(new Error('subagent-claude-code: run cancelled locally'))
|
||||
}
|
||||
}
|
||||
const onAbort = (): void => { requestCancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
let child: SubprocessHandle | undefined
|
||||
let query: Query | undefined
|
||||
try {
|
||||
query = (spec.query ?? officialQuery)({
|
||||
prompt,
|
||||
options: claudeQueryOptions(spec, controller, (captured) => {
|
||||
child = captured
|
||||
}),
|
||||
})
|
||||
if (child === undefined || child.pid <= 0) {
|
||||
throw new Error(
|
||||
'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
|
||||
)
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error('subagent-claude-code: request was aborted before SDK startup')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
const cancelledBeforeCleanup = controller.signal.aborted
|
||||
requestCancel()
|
||||
if (child !== undefined) {
|
||||
try {
|
||||
await disposeClaudeCodeChild(query, child, spec.disposeGraceMs)
|
||||
} catch (disposeError: unknown) {
|
||||
throw new AggregateError(
|
||||
[thrown(error), thrown(disposeError)],
|
||||
'subagent-claude-code: startup failed and CLI cleanup also failed',
|
||||
)
|
||||
}
|
||||
} else if (query !== undefined) {
|
||||
try {
|
||||
query.close()
|
||||
} catch (disposeError: unknown) {
|
||||
throw new AggregateError(
|
||||
[thrown(error), thrown(disposeError)],
|
||||
'subagent-claude-code: startup failed and query cleanup also failed',
|
||||
)
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited.
|
||||
if (cancelledBeforeCleanup || request.signal.aborted) {
|
||||
throw new Error('subagent-claude-code: request was aborted before SDK startup')
|
||||
}
|
||||
throw thrown(error)
|
||||
}
|
||||
|
||||
let output: ContentBlock[] = []
|
||||
const publishedQuery = query
|
||||
const publishedChild = child
|
||||
const result = settleRunResult({
|
||||
attempt: () => consumeClaudeQuery(publishedQuery, (value) => {
|
||||
output = value
|
||||
}),
|
||||
collectOutput: () => output,
|
||||
cancelled: () => controller.signal.aborted,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
})
|
||||
|
||||
return subprocessRunHandle({
|
||||
id: SessionId(randomUUID()),
|
||||
result,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
requestCancel,
|
||||
teardown: () => disposeClaudeCodeChild(
|
||||
publishedQuery,
|
||||
publishedChild,
|
||||
spec.disposeGraceMs,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
LOADER_SMOKE_TEST_TIMEOUT_MS,
|
||||
runLoaderSmoke,
|
||||
} from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const fixtureDir = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/',
|
||||
import.meta.url,
|
||||
))
|
||||
const driver = join(fixtureDir, 'driver.ts')
|
||||
const configPath = join(fixtureDir, 'cordis.yml')
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
describe('product-provider public Loader composition', () => {
|
||||
it('loads both opt-in packages and foreground tools without starting either product', async () => {
|
||||
const { stdout, stderr } = await runLoaderSmoke({
|
||||
label: 'product-provider Loader composition',
|
||||
tempDirPrefix: 'dsh-product-provider-loader-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: {
|
||||
// Loading either optional package must not probe or start its binary.
|
||||
PATH: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
registeredProviders: ['codex', 'claude-code'],
|
||||
providers: [
|
||||
{
|
||||
name: 'codex',
|
||||
capabilities: {
|
||||
outputSchema: false,
|
||||
depthLimit: false,
|
||||
toolFilter: false,
|
||||
persona: false,
|
||||
},
|
||||
inheritsParentContext: false,
|
||||
},
|
||||
{
|
||||
name: 'claude-code',
|
||||
capabilities: {
|
||||
outputSchema: false,
|
||||
depthLimit: false,
|
||||
toolFilter: false,
|
||||
persona: false,
|
||||
},
|
||||
inheritsParentContext: false,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'subagent_codex',
|
||||
parameterNames: ['description', 'prompt'],
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
{
|
||||
name: 'subagent_claude_code',
|
||||
parameterNames: ['description', 'prompt'],
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
],
|
||||
starts: 0,
|
||||
})
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
163
packages/subagent/subagent-claude-code/tests/messages-fixture.ts
Normal file
163
packages/subagent/subagent-claude-code/tests/messages-fixture.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http'
|
||||
|
||||
/** One deterministic response emitted by the package-private Messages server. */
|
||||
export type MessagesBehavior =
|
||||
| { readonly kind: 'complete'; readonly text: string }
|
||||
| { readonly kind: 'hold' }
|
||||
|
||||
/** One recorded Anthropic Messages request. */
|
||||
interface RecordedMessagesRequest {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly headers: IncomingHttpHeaders
|
||||
readonly body: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Running package-private Anthropic Messages fixture. */
|
||||
export interface MessagesFixture {
|
||||
readonly baseUrl: string
|
||||
readonly requests: RecordedMessagesRequest[]
|
||||
readonly requestStarted: Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function event(
|
||||
response: ServerResponse,
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
|
||||
}
|
||||
|
||||
function complete(
|
||||
response: ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
text: string,
|
||||
): void {
|
||||
const model = typeof body.model === 'string' ? body.model : 'fixture-model'
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive',
|
||||
})
|
||||
event(response, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: 'msg_dsh_fixture',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 7,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
event(response, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' },
|
||||
})
|
||||
event(response, 'content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text },
|
||||
})
|
||||
event(response, 'content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: 0,
|
||||
})
|
||||
event(response, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
})
|
||||
event(response, 'message_stop', { type: 'message_stop' })
|
||||
response.end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback-only Anthropic Messages SSE fixture.
|
||||
* @param script - one behavior per Messages request.
|
||||
* @returns the bound server and its recorded requests.
|
||||
*/
|
||||
export async function startMessagesFixture(
|
||||
script: readonly MessagesBehavior[],
|
||||
): Promise<MessagesFixture> {
|
||||
const requests: RecordedMessagesRequest[] = []
|
||||
let requestStartedResolve!: () => void
|
||||
const requestStarted = new Promise<void>((resolve) => {
|
||||
requestStartedResolve = resolve
|
||||
})
|
||||
let behaviorIndex = 0
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
request.on('end', () => {
|
||||
const path = request.url ?? ''
|
||||
if (!path.startsWith('/v1/messages')) {
|
||||
response.writeHead(404, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
type: 'error',
|
||||
error: { type: 'not_found_error', message: `unexpected path ${path}` },
|
||||
}))
|
||||
return
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
const body = JSON.parse(text) as Record<string, unknown>
|
||||
requests.push({
|
||||
method: request.method ?? '',
|
||||
path,
|
||||
headers: request.headers,
|
||||
body,
|
||||
})
|
||||
requestStartedResolve()
|
||||
const behavior = script[behaviorIndex++]
|
||||
if (behavior === undefined) {
|
||||
response.writeHead(500, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'Messages fixture script was exhausted',
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (behavior.kind === 'complete') {
|
||||
complete(response, body, behavior.text)
|
||||
}
|
||||
// A hold deliberately leaves the response pending until client abort.
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('Messages fixture did not bind a TCP port')
|
||||
}
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
requestStarted,
|
||||
async close(): Promise<void> {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error !== undefined) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as claudeCode from '../src/index.ts'
|
||||
import {
|
||||
startMessagesFixture,
|
||||
type MessagesBehavior,
|
||||
type MessagesFixture,
|
||||
} from './messages-fixture.ts'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const sdkRoot = dirname(fileURLToPath(
|
||||
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
|
||||
))
|
||||
const sdkPackage = JSON.parse(readFileSync(
|
||||
join(sdkRoot, 'package.json'),
|
||||
'utf8',
|
||||
)) as {
|
||||
version: string
|
||||
claudeCodeVersion: string
|
||||
optionalDependencies: Record<string, string>
|
||||
}
|
||||
const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
|
||||
const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
|
||||
const claudeBin = join(
|
||||
platformRoot,
|
||||
process.platform === 'win32' ? 'claude.exe' : 'claude',
|
||||
)
|
||||
const settingsModel = 'dsh-settings-inheritance-marker'
|
||||
const fakeKey = 'dsh-fake-anthropic-key'
|
||||
|
||||
const roots: string[] = []
|
||||
const fixtures: MessagesFixture[] = []
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
interface RealHarness {
|
||||
readonly ctx: Context
|
||||
readonly handles: SubprocessHandle[]
|
||||
readonly parent: Agent
|
||||
readonly workspace: string
|
||||
readonly env: Record<string, string>
|
||||
}
|
||||
|
||||
async function realHarness(script: readonly MessagesBehavior[]): Promise<{
|
||||
readonly harness: RealHarness
|
||||
readonly fixture: MessagesFixture
|
||||
}> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-'))
|
||||
roots.push(root)
|
||||
const workspace = join(root, 'workspace')
|
||||
const claudeConfig = join(root, 'claude-config')
|
||||
const xdgConfig = join(root, 'xdg')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(claudeConfig)
|
||||
mkdirSync(xdgConfig)
|
||||
writeFileSync(
|
||||
join(claudeConfig, 'settings.json'),
|
||||
`${JSON.stringify({ model: settingsModel }, null, 2)}\n`,
|
||||
)
|
||||
const fixture = await startMessagesFixture(script)
|
||||
fixtures.push(fixture)
|
||||
const env = {
|
||||
ANTHROPIC_API_KEY: fakeKey,
|
||||
ANTHROPIC_BASE_URL: fixture.baseUrl,
|
||||
CLAUDE_CONFIG_DIR: claudeConfig,
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: xdgConfig,
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
|
||||
DISABLE_TELEMETRY: '1',
|
||||
DISABLE_ERROR_REPORTING: '1',
|
||||
HTTP_PROXY: '',
|
||||
HTTPS_PROXY: '',
|
||||
ALL_PROXY: '',
|
||||
NO_PROXY: '127.0.0.1,localhost',
|
||||
}
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const handles: SubprocessHandle[] = []
|
||||
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = spawn(spec)
|
||||
handles.push(handle)
|
||||
return handle
|
||||
})
|
||||
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
|
||||
const parent = {
|
||||
id: 'real-parent',
|
||||
session: { header: { cwd: workspace } },
|
||||
} as unknown as Agent
|
||||
return {
|
||||
harness: { ctx, handles, parent, workspace, env },
|
||||
fixture,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectQuiescent(
|
||||
handles: readonly SubprocessHandle[],
|
||||
): Promise<void> {
|
||||
expect(handles.length).toBeGreaterThan(0)
|
||||
for (const handle of handles) {
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
const outcome = await handle.done
|
||||
expect(outcome).toHaveProperty('exitCode')
|
||||
expect(outcome).toHaveProperty('signal')
|
||||
}
|
||||
}
|
||||
|
||||
function startRequest(
|
||||
harness: RealHarness,
|
||||
prompt: string,
|
||||
signal = new AbortController().signal,
|
||||
) {
|
||||
return harness.ctx.subagents.start('claude-code', {
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
parent: harness.parent,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
|
||||
timeout: 60_000,
|
||||
}, () => {
|
||||
it('inherits host settings and sends the exact task and fake key to local Messages', async () => {
|
||||
const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220'
|
||||
const task = 'Return the fixture sentinel exactly.'
|
||||
const { harness, fixture } = await realHarness([
|
||||
{ kind: 'complete', text: sentinel },
|
||||
])
|
||||
expect(sdkPackage.version).toBe('0.3.220')
|
||||
expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
|
||||
expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
|
||||
const version = await execFileAsync(claudeBin, ['--version'], {
|
||||
env: { ...process.env, ...harness.env },
|
||||
})
|
||||
expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
|
||||
|
||||
const run = await startRequest(harness, task)
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: sentinel }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
await run.dispose()
|
||||
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
const recorded = fixture.requests[0]!
|
||||
expect(recorded.method).toBe('POST')
|
||||
expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/)
|
||||
expect(recorded.headers['x-api-key']).toBe(fakeKey)
|
||||
expect(recorded.body.model).toBe(settingsModel)
|
||||
expect(Array.isArray(recorded.body.messages)).toBe(true)
|
||||
const messageTexts = (
|
||||
recorded.body.messages as Array<{ content?: unknown }>
|
||||
).flatMap((message): unknown[] =>
|
||||
Array.isArray(message.content) ? message.content as unknown[] : [])
|
||||
.filter((block): block is { type: string; text: string } =>
|
||||
typeof block === 'object'
|
||||
&& block !== null
|
||||
&& 'type' in block
|
||||
&& block.type === 'text'
|
||||
&& 'text' in block
|
||||
&& typeof block.text === 'string')
|
||||
.map(block => block.text)
|
||||
expect(messageTexts.filter(text => text.includes(task))).toEqual([task])
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('maps a real CLI process failure to error', async () => {
|
||||
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
|
||||
const run = await startRequest(harness, 'Exercise the failure path.')
|
||||
await fixture.requestStarted
|
||||
expect(harness.handles).toHaveLength(1)
|
||||
harness.handles[0]!.terminate()
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'error',
|
||||
})
|
||||
await run.dispose()
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey)
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => {
|
||||
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
|
||||
const controller = new AbortController()
|
||||
const run = await startRequest(
|
||||
harness,
|
||||
'Wait for cancellation.',
|
||||
controller.signal,
|
||||
)
|
||||
await fixture.requestStarted
|
||||
controller.abort(new Error('real product cancellation'))
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'aborted',
|
||||
})
|
||||
await run.dispose()
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,845 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type {
|
||||
Query,
|
||||
SDKMessage,
|
||||
SDKResultMessage,
|
||||
SpawnOptions,
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
SubprocessSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as claudeCode from '../src/index.ts'
|
||||
import * as invariant from '../src/invariant.ts'
|
||||
import {
|
||||
claudeSpawnSpec,
|
||||
definedEnvironment,
|
||||
ManagedClaudeCodeProcess,
|
||||
} from '../src/process.ts'
|
||||
import {
|
||||
claudeQueryOptions,
|
||||
consumeClaudeQuery,
|
||||
disposeClaudeCodeChild,
|
||||
startClaudeCodeRun,
|
||||
successfulResult,
|
||||
textTask,
|
||||
type ClaudeCodeRunSpec,
|
||||
} from '../src/run.ts'
|
||||
|
||||
const fakeParent = {
|
||||
id: 'parent',
|
||||
session: { header: { cwd: process.cwd() } },
|
||||
} as unknown as Agent
|
||||
|
||||
function request(
|
||||
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
|
||||
signal = new AbortController().signal,
|
||||
) {
|
||||
return { prompt, parent: fakeParent, signal }
|
||||
}
|
||||
|
||||
async function nextTask(): Promise<void> {
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
}
|
||||
|
||||
interface FakeChildOptions {
|
||||
readonly pid?: number
|
||||
readonly stdin?: PassThrough | undefined
|
||||
readonly stdout?: PassThrough | undefined
|
||||
readonly exitOnTerminate?: boolean
|
||||
readonly waitForExitResult?: boolean
|
||||
readonly waitForExitError?: Error
|
||||
readonly doneError?: Error
|
||||
}
|
||||
|
||||
interface FakeChild {
|
||||
readonly handle: SubprocessHandle
|
||||
readonly stdin: PassThrough
|
||||
readonly stdout: PassThrough
|
||||
readonly settle: (outcome?: SubprocessOutcome) => void
|
||||
readonly fail: (error: Error) => void
|
||||
readonly terminate: Mock<SubprocessHandle['terminate']>
|
||||
readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
|
||||
}
|
||||
|
||||
function fakeChild(options: FakeChildOptions = {}): FakeChild {
|
||||
const stdin = new PassThrough()
|
||||
const stdout = new PassThrough()
|
||||
let exited = false
|
||||
let resolveDone!: (outcome: SubprocessOutcome) => void
|
||||
let rejectDone!: (error: Error) => void
|
||||
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
|
||||
resolveDone = resolve
|
||||
rejectDone = reject
|
||||
})
|
||||
// Individual tests deliberately exercise rejected and still-pending handles.
|
||||
void done.catch(() => {})
|
||||
const settle = (
|
||||
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
|
||||
): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
resolveDone(outcome)
|
||||
}
|
||||
const fail = (error: Error): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
rejectDone(error)
|
||||
}
|
||||
if (options.doneError !== undefined) fail(options.doneError)
|
||||
const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
|
||||
if (options.exitOnTerminate !== false) settle()
|
||||
})
|
||||
const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
|
||||
if (options.waitForExitError !== undefined) {
|
||||
throw options.waitForExitError
|
||||
}
|
||||
if (options.waitForExitResult !== undefined) {
|
||||
return options.waitForExitResult
|
||||
}
|
||||
if (exited) return true
|
||||
if (signal === undefined) {
|
||||
await done.catch(() => {})
|
||||
return true
|
||||
}
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const onAbort = (): void => { resolve(false) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void done.then(
|
||||
() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(true)
|
||||
},
|
||||
() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(true)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
const handle: SubprocessHandle = {
|
||||
pid: options.pid ?? 1234,
|
||||
stdin: options.stdin === undefined ? stdin : options.stdin,
|
||||
stdout: options.stdout === undefined ? stdout : options.stdout,
|
||||
stderr: undefined,
|
||||
collected: {},
|
||||
done,
|
||||
terminate,
|
||||
waitForExit,
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
stdin,
|
||||
stdout,
|
||||
settle,
|
||||
fail,
|
||||
terminate,
|
||||
waitForExit,
|
||||
}
|
||||
}
|
||||
|
||||
function success(
|
||||
result = 'answer',
|
||||
isError = false,
|
||||
): SDKResultMessage {
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: isError,
|
||||
result,
|
||||
} as SDKResultMessage
|
||||
}
|
||||
|
||||
type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
|
||||
|
||||
function failure(
|
||||
subtype: ErrorSubtype,
|
||||
errors: string[] = ['fixture failure'],
|
||||
): SDKResultMessage {
|
||||
return {
|
||||
type: 'result',
|
||||
subtype,
|
||||
is_error: true,
|
||||
errors,
|
||||
} as SDKResultMessage
|
||||
}
|
||||
|
||||
function queryFrom(
|
||||
messages: readonly SDKMessage[],
|
||||
after?: Error,
|
||||
close = vi.fn(),
|
||||
): Query {
|
||||
async function* stream(): AsyncGenerator<SDKMessage, void> {
|
||||
for (const message of messages) yield message
|
||||
if (after !== undefined) throw after
|
||||
}
|
||||
return Object.assign(stream(), { close }) as unknown as Query
|
||||
}
|
||||
|
||||
function waitingQuery(signal: AbortSignal, close = vi.fn()): Query {
|
||||
async function* stream(): AsyncGenerator<SDKMessage, void> {
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
const fail = (): void => {
|
||||
reject(signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal.reason)))
|
||||
}
|
||||
if (signal.aborted) fail()
|
||||
else signal.addEventListener('abort', fail, { once: true })
|
||||
})
|
||||
}
|
||||
return Object.assign(stream(), { close }) as unknown as Query
|
||||
}
|
||||
|
||||
function sdkSpawnOptions(
|
||||
overrides: Partial<SpawnOptions> = {},
|
||||
): SpawnOptions {
|
||||
return {
|
||||
command: '/sdk/claude',
|
||||
args: ['--output-format', 'stream-json'],
|
||||
cwd: '/workspace',
|
||||
env: { PATH: '/bin', OMITTED: undefined },
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeRun {
|
||||
readonly child: FakeChild
|
||||
readonly query: Query
|
||||
readonly close: ReturnType<typeof vi.fn>
|
||||
readonly spawnSpecs: SubprocessSpawnSpec[]
|
||||
readonly options: Array<Parameters<NonNullable<ClaudeCodeRunSpec['query']>>[0]['options']>
|
||||
readonly spec: ClaudeCodeRunSpec
|
||||
}
|
||||
|
||||
function fakeRun(
|
||||
messages: readonly SDKMessage[] = [success()],
|
||||
after?: Error,
|
||||
child = fakeChild(),
|
||||
): FakeRun {
|
||||
const close = vi.fn()
|
||||
const query = queryFrom(messages, after, close)
|
||||
const spawnSpecs: SubprocessSpawnSpec[] = []
|
||||
const options: FakeRun['options'] = []
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: '/workspace',
|
||||
env: { ANTHROPIC_API_KEY: 'fake-key' },
|
||||
disposeGraceMs: 5,
|
||||
spawn: (spawnSpec) => {
|
||||
spawnSpecs.push(spawnSpec)
|
||||
return child.handle
|
||||
},
|
||||
query: (params) => {
|
||||
options.push(params.options)
|
||||
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
||||
return query
|
||||
},
|
||||
}
|
||||
return { child, query, close, spawnSpecs, options, spec }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('task admission and package contracts', () => {
|
||||
it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
|
||||
expect(textTask([
|
||||
{ type: 'text', text: 'one' },
|
||||
{ type: 'text', text: 'two' },
|
||||
])).toBe('onetwo')
|
||||
expect(() => textTask([])).toThrow('only text blocks')
|
||||
expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
|
||||
.toThrow('only text blocks')
|
||||
expect(() => textTask([{ type: 'text', text: ' \n ' }]))
|
||||
.toThrow('must not be empty')
|
||||
})
|
||||
|
||||
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const fiber = await ctx.plugin(claudeCode, {})
|
||||
expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
|
||||
name: 'claude-code',
|
||||
capabilities: {
|
||||
outputSchema: false,
|
||||
depthLimit: false,
|
||||
toolFilter: false,
|
||||
persona: false,
|
||||
},
|
||||
inheritsParentContext: false,
|
||||
})
|
||||
expect(ctx.subagents.list()).toEqual(['claude-code'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
|
||||
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
|
||||
.rejects.toThrow('disposeGraceMs must be a positive finite number')
|
||||
}
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('starts through the registered provider with its resolved config and diagnostics', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const child = fakeChild()
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
||||
.mockImplementation(() => child.handle)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
await ctx.plugin(claudeCode, {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: 'provider-fake-key',
|
||||
CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
|
||||
HOME: '/private/tmp/dsh-claude-code-unit-home',
|
||||
},
|
||||
disposeGraceMs: 29,
|
||||
})
|
||||
|
||||
const run = await ctx.subagents.start('claude-code', request())
|
||||
child.settle({ exitCode: 9, signal: null })
|
||||
child.stdout.end()
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'error',
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'subagent-claude-code: child run failed (error):',
|
||||
))
|
||||
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
cwd: process.cwd(),
|
||||
graceMs: 29,
|
||||
}))
|
||||
expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
|
||||
ANTHROPIC_API_KEY: 'provider-fake-key',
|
||||
})
|
||||
await run.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
|
||||
expect('default' in claudeCode).toBe(false)
|
||||
expect(claudeCode.name).toBe('subagent-claude-code')
|
||||
expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
|
||||
|
||||
const dispose = vi.fn()
|
||||
const register = vi.fn((
|
||||
_packageName: string,
|
||||
_installer: InvariantInstaller,
|
||||
) => dispose)
|
||||
const ctx = { invariants: { register } } as unknown as Context
|
||||
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
'@deepseek-ai/dsh-subagent-claude-code',
|
||||
expect.any(Function),
|
||||
)
|
||||
const install = register.mock.calls[0]![1]
|
||||
await install(new Context(), (message) => { throw new Error(message) })
|
||||
expect(invariant.name).toBe('subagent-claude-code-invariant')
|
||||
expect(invariant.inject).toEqual(['invariants'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('official spawn projection', () => {
|
||||
it('forwards command, arguments, cwd, environment, and signal exactly', () => {
|
||||
const signal = new AbortController().signal
|
||||
const options = sdkSpawnOptions({
|
||||
command: '/official/claude',
|
||||
args: ['--one', 'two'],
|
||||
cwd: '/parent/workspace',
|
||||
env: { A: 'one', B: undefined, C: 'three' },
|
||||
signal,
|
||||
})
|
||||
expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' })
|
||||
expect(claudeSpawnSpec(options, 321)).toEqual({
|
||||
argv: ['/official/claude', '--one', 'two'],
|
||||
cwd: '/parent/workspace',
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
||||
graceMs: 321,
|
||||
signal,
|
||||
env: { A: 'one', C: 'three' },
|
||||
})
|
||||
const missingCwd = sdkSpawnOptions()
|
||||
delete missingCwd.cwd
|
||||
expect(() => claudeSpawnSpec(
|
||||
missingCwd,
|
||||
321,
|
||||
)).toThrow('SDK spawn request omitted its workspace')
|
||||
expect(() => claudeSpawnSpec(
|
||||
sdkSpawnOptions({ cwd: '' }),
|
||||
321,
|
||||
)).toThrow('SDK spawn request omitted its workspace')
|
||||
})
|
||||
|
||||
it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
|
||||
const child = fakeChild({ exitOnTerminate: false })
|
||||
const process = new ManagedClaudeCodeProcess(child.handle)
|
||||
expect(process.stdin).toBe(child.stdin)
|
||||
expect(process.stdout).toBe(child.stdout)
|
||||
expect(process.killed).toBe(false)
|
||||
expect(process.exitCode).toBeNull()
|
||||
expect(process.signalCode).toBeNull()
|
||||
|
||||
const exit = vi.fn()
|
||||
const once = vi.fn()
|
||||
const removed = vi.fn()
|
||||
process.on('exit', exit)
|
||||
process.once('exit', once)
|
||||
process.on('exit', removed)
|
||||
process.off('exit', removed)
|
||||
expect(process.kill('SIGTERM')).toBe(true)
|
||||
expect(process.killed).toBe(true)
|
||||
expect(process.kill('SIGKILL')).toBe(false)
|
||||
expect(child.terminate).toHaveBeenCalledOnce()
|
||||
|
||||
child.settle({ exitCode: null, signal: 'SIGTERM' })
|
||||
await nextTask()
|
||||
expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
|
||||
expect(once).toHaveBeenCalledOnce()
|
||||
expect(removed).not.toHaveBeenCalled()
|
||||
expect(process.signalCode).toBe('SIGTERM')
|
||||
expect(process.kill('SIGTERM')).toBe(false)
|
||||
})
|
||||
|
||||
it('emits spawn errors and rejects handles without the required pipes', async () => {
|
||||
const child = fakeChild()
|
||||
const process = new ManagedClaudeCodeProcess(child.handle)
|
||||
const errorListener = vi.fn()
|
||||
const removed = vi.fn()
|
||||
process.once('error', errorListener)
|
||||
process.on('error', removed)
|
||||
process.off('error', removed)
|
||||
child.fail(new Error('spawn boom'))
|
||||
await nextTask()
|
||||
expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'spawn boom',
|
||||
}))
|
||||
expect(removed).not.toHaveBeenCalled()
|
||||
|
||||
const missingStdin = fakeChild({ stdin: undefined })
|
||||
Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined })
|
||||
expect(() => new ManagedClaudeCodeProcess(missingStdin.handle))
|
||||
.toThrow('requires piped stdin and stdout')
|
||||
const missingStdout = fakeChild({ stdout: undefined })
|
||||
Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined })
|
||||
expect(() => new ManagedClaudeCodeProcess(missingStdout.handle))
|
||||
.toThrow('requires piped stdin and stdout')
|
||||
})
|
||||
|
||||
it('exposes a settled direct-child exit code', async () => {
|
||||
const child = fakeChild()
|
||||
const process = new ManagedClaudeCodeProcess(child.handle)
|
||||
child.settle({ exitCode: 7, signal: null })
|
||||
await nextTask()
|
||||
expect(process.exitCode).toBe(7)
|
||||
expect(process.signalCode).toBeNull()
|
||||
expect(process.kill('SIGTERM')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('query options and result mapping', () => {
|
||||
it('builds the fixed unattended options over the scrubbed environment', () => {
|
||||
vi.stubEnv('HOST_VISIBLE', 'visible')
|
||||
vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
|
||||
vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
|
||||
const child = fakeChild()
|
||||
const spawn = vi.fn(() => child.handle)
|
||||
const captured: SubprocessHandle[] = []
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: '/workspace',
|
||||
env: {
|
||||
HOST_VISIBLE: 'overridden',
|
||||
ANTHROPIC_API_KEY: 'explicit-fake-key',
|
||||
},
|
||||
disposeGraceMs: 17,
|
||||
spawn,
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const options = claudeQueryOptions(spec, controller, (value) => {
|
||||
captured.push(value)
|
||||
})
|
||||
|
||||
expect(options).toMatchObject({
|
||||
abortController: controller,
|
||||
cwd: '/workspace',
|
||||
persistSession: false,
|
||||
disallowedTools: ['AskUserQuestion'],
|
||||
})
|
||||
expect(options.env).toMatchObject({
|
||||
HOST_VISIBLE: 'overridden',
|
||||
ANTHROPIC_API_KEY: 'explicit-fake-key',
|
||||
})
|
||||
expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
|
||||
expect(options.env).not.toHaveProperty('DSH_INTERNAL')
|
||||
for (const omitted of [
|
||||
'settingSources',
|
||||
'canUseTool',
|
||||
'onElicitation',
|
||||
'onUserDialog',
|
||||
'supportedDialogKinds',
|
||||
]) {
|
||||
expect(options).not.toHaveProperty(omitted)
|
||||
}
|
||||
|
||||
const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
||||
expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
|
||||
expect(captured).toEqual([child.handle])
|
||||
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
argv: ['/sdk/claude', '--output-format', 'stream-json'],
|
||||
cwd: '/workspace',
|
||||
graceMs: 17,
|
||||
}))
|
||||
})
|
||||
|
||||
it('accepts only a non-error success with a non-blank final result', () => {
|
||||
expect(successfulResult(success('exact final'))).toBe('exact final')
|
||||
expect(() => successfulResult(success('answer', true)))
|
||||
.toThrow('marked as an error')
|
||||
expect(() => successfulResult(success(' \n ')))
|
||||
.toThrow('contained no answer')
|
||||
expect(() => successfulResult(failure(
|
||||
'error_during_execution',
|
||||
['first', 'second'],
|
||||
))).toThrow('first; second')
|
||||
expect(() => successfulResult(failure(
|
||||
'error_max_turns',
|
||||
[],
|
||||
))).toThrow('error_max_turns')
|
||||
})
|
||||
|
||||
it('consumes the complete stream and keeps the latest strict success', async () => {
|
||||
const outputs: ContentBlock[][] = []
|
||||
const query = queryFrom([
|
||||
{ type: 'system', subtype: 'init' } as SDKMessage,
|
||||
success('first'),
|
||||
success('last'),
|
||||
])
|
||||
await expect(consumeClaudeQuery(query, (output) => {
|
||||
outputs.push(output)
|
||||
})).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'last' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
expect(outputs).toEqual([
|
||||
[{ type: 'text', text: 'first' }],
|
||||
[{ type: 'text', text: 'last' }],
|
||||
])
|
||||
await expect(consumeClaudeQuery(
|
||||
queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
|
||||
() => {},
|
||||
)).rejects.toThrow('ended without a result')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run publication, cancellation, and settlement', () => {
|
||||
it('publishes only after Query and managed child exist, then disposes once', async () => {
|
||||
const fixture = fakeRun([success('exact answer')])
|
||||
const run = await startClaudeCodeRun(
|
||||
request([
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: 'second' },
|
||||
]),
|
||||
fixture.spec,
|
||||
)
|
||||
expect(fixture.options).toHaveLength(1)
|
||||
expect(fixture.spawnSpecs).toHaveLength(1)
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'exact answer' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
const first = run.dispose()
|
||||
const second = run.dispose()
|
||||
expect(second).toBe(first)
|
||||
await first
|
||||
expect(fixture.close).toHaveBeenCalledOnce()
|
||||
expect(fixture.child.terminate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('flattens every SDK error result without inventing shared stop reasons', async () => {
|
||||
const subtypes: ErrorSubtype[] = [
|
||||
'error_during_execution',
|
||||
'error_max_turns',
|
||||
'error_max_budget_usd',
|
||||
'error_max_structured_output_retries',
|
||||
]
|
||||
for (const subtype of subtypes) {
|
||||
const fixture = fakeRun([failure(subtype)])
|
||||
const onError = vi.fn()
|
||||
const run = await startClaudeCodeRun(
|
||||
request(),
|
||||
{ ...fixture.spec, onError },
|
||||
)
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'error',
|
||||
})
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'error',
|
||||
)
|
||||
await run.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves candidate output when iteration fails after a result', async () => {
|
||||
const fixture = fakeRun(
|
||||
[success('partial final')],
|
||||
new Error('iterator boom'),
|
||||
)
|
||||
const run = await startClaudeCodeRun(request(), fixture.spec)
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'partial final' }],
|
||||
stopReason: 'error',
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('maps invalid success and missing result to error', async () => {
|
||||
for (const messages of [
|
||||
[success('answer', true)],
|
||||
[success('')],
|
||||
[{ type: 'system', subtype: 'init' } as SDKMessage],
|
||||
]) {
|
||||
const fixture = fakeRun(messages)
|
||||
const run = await startClaudeCodeRun(request(), fixture.spec)
|
||||
await expect(run.result).resolves.toMatchObject({
|
||||
stopReason: 'error',
|
||||
})
|
||||
await run.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives local cancellation precedence and isolates overlapping controllers', async () => {
|
||||
const firstChild = fakeChild()
|
||||
const secondChild = fakeChild()
|
||||
const children = [firstChild, secondChild]
|
||||
const controllers: AbortController[] = []
|
||||
let index = 0
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: '/workspace',
|
||||
env: {},
|
||||
disposeGraceMs: 5,
|
||||
spawn: () => children[index++]!.handle,
|
||||
query: ({ prompt, options }) => {
|
||||
controllers.push(options.abortController!)
|
||||
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
||||
return prompt === 'wait'
|
||||
? waitingQuery(options.abortController!.signal)
|
||||
: queryFrom([success('second answer')])
|
||||
},
|
||||
}
|
||||
const firstAbort = new AbortController()
|
||||
const first = await startClaudeCodeRun(
|
||||
request([{ type: 'text', text: 'wait' }], firstAbort.signal),
|
||||
spec,
|
||||
)
|
||||
const second = await startClaudeCodeRun(
|
||||
request([{ type: 'text', text: 'finish' }]),
|
||||
spec,
|
||||
)
|
||||
expect(controllers).toHaveLength(2)
|
||||
expect(controllers[0]).not.toBe(controllers[1])
|
||||
firstAbort.abort(new Error('parent cancelled'))
|
||||
await expect(first.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'aborted',
|
||||
})
|
||||
await expect(second.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'second answer' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
expect(controllers[1]!.signal.aborted).toBe(false)
|
||||
await Promise.all([first.dispose(), second.dispose()])
|
||||
})
|
||||
|
||||
it('rejects pre-abort and every incomplete startup transaction', async () => {
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
const unused = fakeRun()
|
||||
await expect(startClaudeCodeRun(
|
||||
request(undefined, preAborted.signal),
|
||||
unused.spec,
|
||||
)).rejects.toThrow('aborted before SDK startup')
|
||||
expect(unused.options).toEqual([])
|
||||
|
||||
const noChildClose = vi.fn()
|
||||
await expect(startClaudeCodeRun(request(), {
|
||||
...unused.spec,
|
||||
query: () => queryFrom([], undefined, noChildClose),
|
||||
})).rejects.toThrow('did not publish a controllable')
|
||||
expect(noChildClose).toHaveBeenCalledOnce()
|
||||
|
||||
const closeFailure = vi.fn(() => { throw new Error('close boom') })
|
||||
const noChild = startClaudeCodeRun(request(), {
|
||||
...unused.spec,
|
||||
query: () => queryFrom([], undefined, closeFailure),
|
||||
})
|
||||
await expect(noChild).rejects.toBeInstanceOf(AggregateError)
|
||||
|
||||
const startupAbort = new AbortController()
|
||||
const abortedChild = fakeChild()
|
||||
const abortedClose = vi.fn()
|
||||
const abortedDuringStartup = startClaudeCodeRun(
|
||||
request(undefined, startupAbort.signal),
|
||||
{
|
||||
...unused.spec,
|
||||
spawn: () => abortedChild.handle,
|
||||
query: ({ options }) => {
|
||||
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
||||
startupAbort.abort(new Error('startup cancelled'))
|
||||
return queryFrom([], undefined, abortedClose)
|
||||
},
|
||||
},
|
||||
)
|
||||
await expect(abortedDuringStartup)
|
||||
.rejects.toThrow('aborted before SDK startup')
|
||||
expect(abortedClose).toHaveBeenCalledOnce()
|
||||
expect(abortedChild.terminate).toHaveBeenCalledOnce()
|
||||
|
||||
await expect(startClaudeCodeRun(request(), {
|
||||
...unused.spec,
|
||||
query: () => {
|
||||
throw new Error('query failed before resource creation')
|
||||
},
|
||||
})).rejects.toThrow('query failed before resource creation')
|
||||
|
||||
const spawned = fakeChild()
|
||||
const spawnSpecs: SubprocessSpawnSpec[] = []
|
||||
let factoryController: AbortController | undefined
|
||||
const factoryFailure = startClaudeCodeRun(request(), {
|
||||
...unused.spec,
|
||||
spawn: (spawnSpec) => {
|
||||
spawnSpecs.push(spawnSpec)
|
||||
return spawned.handle
|
||||
},
|
||||
query: ({ options }) => {
|
||||
factoryController = options.abortController
|
||||
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
||||
throw new Error('query construction failed')
|
||||
},
|
||||
})
|
||||
await expect(factoryFailure).rejects.toThrow('query construction failed')
|
||||
expect(spawnSpecs).toHaveLength(1)
|
||||
expect(factoryController?.signal.aborted).toBe(true)
|
||||
expect(spawned.terminate).toHaveBeenCalledOnce()
|
||||
|
||||
const failedSpawn = fakeChild({
|
||||
pid: -1,
|
||||
doneError: new Error('spawn failed'),
|
||||
})
|
||||
const failed = fakeRun([], undefined, failedSpawn)
|
||||
await expect(startClaudeCodeRun(request(), failed.spec))
|
||||
.rejects.toBeInstanceOf(AggregateError)
|
||||
expect(failed.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bounded query and process disposal', () => {
|
||||
it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
|
||||
const child = fakeChild()
|
||||
const close = vi.fn()
|
||||
await disposeClaudeCodeChild({ close }, child.handle, 5)
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(child.terminate).toHaveBeenCalledOnce()
|
||||
expect(child.waitForExit).toHaveBeenCalledOnce()
|
||||
await expect(child.handle.done).resolves.toEqual({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts fractional and larger-than-Node grace windows', async () => {
|
||||
for (const graceMs of [0.25, Number.MAX_VALUE]) {
|
||||
const child = fakeChild()
|
||||
await expect(disposeClaudeCodeChild(
|
||||
{ close: vi.fn() },
|
||||
child.handle,
|
||||
graceMs,
|
||||
)).resolves.toBeUndefined()
|
||||
const signal = child.waitForExit.mock.calls[0]?.[0]
|
||||
expect(signal?.aborted).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('chains a doubled grace window beyond one Node timer segment', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const child = fakeChild({ exitOnTerminate: false })
|
||||
const disposal = disposeClaudeCodeChild(
|
||||
{ close: vi.fn() },
|
||||
child.handle,
|
||||
1_073_741_823.75,
|
||||
)
|
||||
const rejected = expect(disposal)
|
||||
.rejects.toThrow('did not exit within its dispose window')
|
||||
await vi.advanceTimersByTimeAsync(2_147_483_647)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not turn a missed tree-exit bound into an unbounded done wait', async () => {
|
||||
const child = fakeChild({
|
||||
exitOnTerminate: false,
|
||||
waitForExitResult: false,
|
||||
})
|
||||
await expect(disposeClaudeCodeChild(
|
||||
{ close: vi.fn() },
|
||||
child.handle,
|
||||
5,
|
||||
)).rejects.toThrow('did not exit within its dispose window')
|
||||
child.fail(new Error('late direct-child failure'))
|
||||
await nextTask()
|
||||
})
|
||||
|
||||
it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
|
||||
const waitFailure = fakeChild({
|
||||
exitOnTerminate: false,
|
||||
waitForExitError: new Error('wait boom'),
|
||||
})
|
||||
const closeFailure = vi.fn(() => { throw new Error('close boom') })
|
||||
await expect(disposeClaudeCodeChild(
|
||||
{ close: closeFailure },
|
||||
waitFailure.handle,
|
||||
5,
|
||||
)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(waitFailure.terminate).toHaveBeenCalledOnce()
|
||||
|
||||
const doneFailure = fakeChild({
|
||||
pid: -1,
|
||||
doneError: new Error('spawn boom'),
|
||||
})
|
||||
await expect(disposeClaudeCodeChild(
|
||||
{ close: vi.fn() },
|
||||
doneFailure.handle,
|
||||
5,
|
||||
)).rejects.toThrow('spawn boom')
|
||||
|
||||
const both = fakeChild({
|
||||
pid: -1,
|
||||
doneError: new Error('spawn boom'),
|
||||
})
|
||||
await expect(disposeClaudeCodeChild(
|
||||
{ close: () => { throw new Error('close boom') } },
|
||||
both.handle,
|
||||
5,
|
||||
)).rejects.toBeInstanceOf(AggregateError)
|
||||
})
|
||||
})
|
||||
28
packages/subagent/subagent-claude-code/tsconfig.json
Normal file
28
packages/subagent/subagent-claude-code/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/types/.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: 4682b06ae105a0ae70ea7e78a80776ac18d817e7
|
||||
README.zh.md: c39afb26d8c6baf4774ae3b4a8f8151529a29e15
|
||||
README.md: a388f5a57fd32768dc9b66e3637ff53bf6479149
|
||||
README.zh.md: 48e5694e82ee0269661fbb5ede75cf995cbc00aa
|
||||
|
||||
@@ -15,6 +15,7 @@ The family separates the stable interface from implementations and model-facing
|
||||
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
|
||||
| `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). |
|
||||
| `@deepseek-ai/dsh-subagent-claude-code` | Fresh official Claude Agent SDK query with a real managed Claude Code CLI child (one-shot). |
|
||||
| `@deepseek-ai/dsh-subagent-dsh-sdk` | Fresh out-of-process harness child driven through the TypeScript SDK client (one-shot). |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. |
|
||||
@@ -64,7 +65,7 @@ The seam owns the versioned `subagent/descriptor` session event vocabulary (`src
|
||||
|
||||
The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level.
|
||||
|
||||
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.
|
||||
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority.
|
||||
|
||||
## One-shot ownership and lifecycle
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 |
|
||||
| `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 |
|
||||
| `@deepseek-ai/dsh-subagent-claude-code` | 通过官方 Claude Agent SDK 启动的全新 query,带有一个真实且受管的 Claude Code CLI 子进程(一次性)。 |
|
||||
| `@deepseek-ai/dsh-subagent-dsh-sdk` | 通过 TypeScript SDK 客户端驱动的全新进程外 harness 子 agent(一次性)。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
|
||||
@@ -64,7 +65,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。
|
||||
|
||||
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。
|
||||
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
|
||||
|
||||
## 一次性所有权与生命周期
|
||||
|
||||
|
||||
Reference in New Issue
Block a user