Add the ACP subagent backend: out-of-process delegation (PR3)
The first OUT-OF-PROCESS subagent backend, proving the seam generalizes past the in-process backends. @deepseek-ai/dsh-subagent-acp runs each child agent in a spawned subprocess, driven over the Agent Client Protocol as the CLIENT — the direction-inverted twin of the dsh-acp server bridge. Point the configured command at the acp-agent example and the harness talks to its own process. - Fresh process per run: start spawns, runs one ACP session (initialize → newSession → prompt), dispose kills the subprocess and awaits its exit. - Minimal client stub: advertises no fs/terminal; accumulates agent_message_chunk text as the result output; auto-answers session/request_permission by a configured policy (reject default / allow). No start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter); ignores request.parent; injects only `subagents`. - StopReason mapping (end_turn→completed, cancelled→aborted, …); result resolves error/aborted on a child failure, never rejects (seam contract). - Security: credential-shaped ambient env vars are scrubbed; the child's own key is forwarded only via explicit config.env. A spawn-level error (ENOENT) is captured and raced against the ACP drive so a bad command settles error rather than crashing the parent. Testing designed at every tier: keyless integration drives a scripted mock ACP server subprocess (cancellation incl. the pre-newSession race and a torn-pipe-after-cancel, permission auto-answer, non-message updates, spawn failure, HMR, export shape) at 100% coverage; a with-key e2e drives the REAL acp-agent example process (PONG + real file write, verified on disk) — the harness driving itself. Snapshot coverage of an ACP child is deferred as TODO(acp-subagent-replay) (each child is its own process with its own replay). Stayed on @agentclientprotocol/sdk 0.25.1: the proposed 0.28.x bump only deprecates the stable ClientSideConnection/AgentSideConnection API this layer uses (33 sites incl. the server bridge), turning no-deprecated red across code this PR shouldn't rewrite — that fluent-API migration is its own follow-up. The backend needs nothing 0.28.x adds. This completes the subagent seam stack (PR1 interface → PR2 in-process → PR2.5 snapshot infra → PR3 ACP); the seam RFC moves to implemented/, amended.
This commit is contained in:
@@ -7,8 +7,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
|
||||
| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` |
|
||||
| `subagent-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (registers on `ctx.subagents`) |
|
||||
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
|
||||
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock.
|
||||
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` and the out-of-process `subagent-acp` backends ship here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock.
|
||||
|
||||
The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md).
|
||||
The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
|
||||
69
packages/subagent/subagent-acp/README.md
Normal file
69
packages/subagent/subagent-acp/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# @deepseek-ai/dsh-subagent-acp
|
||||
|
||||
The out-of-process **ACP subagent backend**: runs each child agent in a spawned subprocess, driven over the [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) as the *client*. Registers a `SubagentProvider` on `ctx.subagents` (the [subagent seam](../subagent)), alongside the in-process [`-spawn`](../subagent-spawn)/[`-fork`](../subagent-fork) backends — multiple backends coexist by name.
|
||||
|
||||
It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/dsh-acp`](../../ui/acp): that package is the ACP *agent* (it answers `initialize`/`newSession`/`prompt`); this one is the ACP *client* (it *calls* them and implements the `sessionUpdate`/`requestPermission` callbacks). Point the configured command at the `acp-agent` example to "talk to our own process".
|
||||
|
||||
## What it does
|
||||
|
||||
`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit.
|
||||
|
||||
**Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC).
|
||||
|
||||
Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend:
|
||||
- injects only `subagents` (no `ctx.agents`);
|
||||
- advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter);
|
||||
- ignores `request.parent`.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `providerName` | string | `acp` | Registry name on `ctx.subagents`. |
|
||||
| `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). |
|
||||
| `args` | string[] | `[]` | Arguments passed to `command`. |
|
||||
| `cwd` | string | parent cwd | Working directory for the child process and its ACP session. |
|
||||
| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. |
|
||||
| `env` | Record<string,string> | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. |
|
||||
|
||||
```yaml
|
||||
- id: subagent-acp
|
||||
name: '@deepseek-ai/dsh-subagent-acp'
|
||||
config:
|
||||
providerName: acp
|
||||
command: node
|
||||
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml']
|
||||
permission: reject
|
||||
env:
|
||||
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
||||
```
|
||||
|
||||
## StopReason mapping
|
||||
|
||||
ACP `StopReason` → harness `SubagentStopReason`:
|
||||
|
||||
| ACP | harness |
|
||||
|---|---|
|
||||
| `end_turn` | `completed` |
|
||||
| `max_tokens` | `max-tokens` |
|
||||
| `refusal` | `refusal` |
|
||||
| `cancelled` | `aborted` |
|
||||
| `max_turn_requests` | `error` (no clean equivalent; the task did not finish) |
|
||||
| _(unknown)_ | `error` |
|
||||
|
||||
A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested) — `result` never rejects on a child-level failure, per the seam contract.
|
||||
|
||||
## Environment scrub
|
||||
|
||||
Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded to the child by default — the parent harness's own secrets must not leak into a spawned process implicitly. The child's OWN credentials are supplied explicitly via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Keyless** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key.
|
||||
- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt and does real file work (verified on disk). Self-skips without `DEEPSEEK_API_KEY`.
|
||||
|
||||
`TODO(acp-subagent-replay)`: snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC.
|
||||
|
||||
## Plugin export shape
|
||||
|
||||
Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
39
packages/subagent/subagent-acp/package.json
Normal file
39
packages/subagent/subagent-acp/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-acp",
|
||||
"description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
90
packages/subagent/subagent-acp/src/index.ts
Normal file
90
packages/subagent/subagent-acp/src/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven
|
||||
* over the Agent Client Protocol (ACP) as the client. The parent process is the
|
||||
* ACP client; the child is any ACP agent (point the configured command at the
|
||||
* `acp-agent` example to "talk to our own process").
|
||||
*
|
||||
* Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share
|
||||
* this cordis context — it is a separate process with its own session, model
|
||||
* client, and tools. So this backend injects only `subagents` (no `agents`),
|
||||
* advertises NO start-time capabilities (an out-of-process child cannot enforce
|
||||
* the parent's depth/tool-filter), and ignores `request.parent`.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
|
||||
* so a stray default would drop the namespace — see docs/postmortem/0001).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
|
||||
export const name = 'subagent-acp'
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: how to spawn and drive the child ACP agent process. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `acp`). */
|
||||
providerName: string
|
||||
/** The executable to spawn for each run (the child ACP agent). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/**
|
||||
* Working directory for the child process and its ACP session. Defaults to
|
||||
* the parent process's cwd when omitted.
|
||||
*/
|
||||
cwd?: string
|
||||
/**
|
||||
* How to auto-answer the child's `session/request_permission` prompts:
|
||||
* `reject` (default — decline every prompt) or `allow` (approve via the first
|
||||
* allow-shaped option). The first cut surfaces no prompt to a human.
|
||||
*/
|
||||
permission: PermissionPolicy
|
||||
/**
|
||||
* Extra environment variables for the child process — e.g. the child
|
||||
* harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
|
||||
* copy of the parent env, so an explicit key here reaches the child while
|
||||
* ambient secrets do not leak implicitly.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
providerName: z.string().default('acp'),
|
||||
command: z.string().required(),
|
||||
args: z.array(z.string()).default([]),
|
||||
cwd: z.string(),
|
||||
permission: z.union(['allow', 'reject'] as const).default('reject'),
|
||||
env: z.dict(z.string()).default({}),
|
||||
})
|
||||
|
||||
/**
|
||||
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
|
||||
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
|
||||
* a request needing any of them before `start` runs).
|
||||
*/
|
||||
class AcpProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
|
||||
constructor(readonly name: string, private readonly config: Config) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const spec: AcpRunSpec = {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
cwd: this.config.cwd ?? process.cwd(),
|
||||
permission: this.config.permission,
|
||||
env: this.config.env,
|
||||
}
|
||||
return startAcpRun(request, spec)
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new AcpProvider(config.providerName, config))
|
||||
}
|
||||
292
packages/subagent/subagent-acp/src/run.ts
Normal file
292
packages/subagent/subagent-acp/src/run.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent run driver. Spawns a child agent as a
|
||||
* subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the
|
||||
* CLIENT, drives one session to completion, and shapes the result into a
|
||||
* {@link SubagentResult}. The mirror image of the server-side bridge in
|
||||
* `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP
|
||||
* *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we
|
||||
* IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`).
|
||||
*
|
||||
* One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly
|
||||
* one ACP session, and `dispose` kills the subprocess and awaits its exit.
|
||||
* Persistent-process pooling is a future optimization (see the RFC).
|
||||
*
|
||||
* TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a
|
||||
* distinct replay shape — each child is its own PROCESS with its own
|
||||
* single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own
|
||||
* sessions-root + fixture), unlike the in-process per-session keying in
|
||||
* `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a
|
||||
* scripted mock ACP server subprocess, and the with-key e2e drives the real
|
||||
* `acp-agent` example. See the ACP-subagent-backend RFC.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp/run
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type ContentBlock as AcpContentBlock,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
/**
|
||||
* How the client answers a child's `session/request_permission`. The first cut
|
||||
* does not surface permission prompts to a human, so every request is
|
||||
* auto-answered by this fixed policy:
|
||||
*
|
||||
* - `reject` — decline every prompt (answer `cancelled`). Safe default: a child
|
||||
* that asks before a side effect does not get to take it.
|
||||
* - `allow` — approve every prompt by selecting its first `allow_*` option (or,
|
||||
* if none is offered, `cancelled`). Use when the child is trusted to act.
|
||||
*/
|
||||
export type PermissionPolicy = 'allow' | 'reject'
|
||||
|
||||
/** Resolved spawn spec for an ACP child process (no defaults — see Config). */
|
||||
export interface AcpRunSpec {
|
||||
/** The executable to spawn (the child ACP agent). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/** Working directory for the child process AND its ACP session `cwd`. */
|
||||
cwd: string
|
||||
/** How to auto-answer the child's permission prompts. */
|
||||
permission: PermissionPolicy
|
||||
/**
|
||||
* Extra environment variables to ADD for the child (e.g. the child harness's
|
||||
* `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see
|
||||
* {@link buildChildEnv}. A value here is forwarded even if its name matches
|
||||
* the credential-scrub pattern (an explicit opt-in for the child's own creds).
|
||||
*/
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential-shaped ambient env vars are NOT forwarded to the child by default
|
||||
* (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
|
||||
* spawned process implicitly). Same pattern as the bash executor. The child
|
||||
* agent needs its OWN credentials to reach a model — those are supplied
|
||||
* explicitly via {@link AcpRunSpec.env}, which is layered on top AFTER the
|
||||
* scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental
|
||||
* `AWS_SECRET_ACCESS_KEY` does not.
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
|
||||
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
|
||||
}
|
||||
return { ...env, ...extra }
|
||||
}
|
||||
|
||||
/** Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. */
|
||||
export function acpStopReason(reason: StopReason): SubagentStopReason {
|
||||
switch (reason) {
|
||||
case 'end_turn':
|
||||
return 'completed'
|
||||
case 'max_tokens':
|
||||
return 'max-tokens'
|
||||
case 'refusal':
|
||||
return 'refusal'
|
||||
case 'cancelled':
|
||||
return 'aborted'
|
||||
// `max_turn_requests` (the child hit its turn-request budget) has no direct
|
||||
// harness equivalent and means the task did NOT finish cleanly — surface it
|
||||
// as a generic failure so the consumer maps it to an isError result rather
|
||||
// than reporting a partial answer as success.
|
||||
case 'max_turn_requests':
|
||||
return 'error'
|
||||
// ACP StopReason is a closed wire union, but a future SDK could add a
|
||||
// variant; treat an unknown terminal reason as a failure (never silently
|
||||
// 'completed').
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect the text of an ACP content block (non-text blocks contribute nothing). */
|
||||
export function acpContentText(content: AcpContentBlock): string {
|
||||
return content.type === 'text' ? content.text : ''
|
||||
}
|
||||
|
||||
/** Translate the harness prompt blocks into ACP prompt blocks (text only). */
|
||||
export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] {
|
||||
const blocks: AcpContentBlock[] = []
|
||||
for (const block of prompt) {
|
||||
if (block.type === 'text') blocks.push({ type: 'text', text: block.text })
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Resolve once the child process exits (any code/signal); immediate if gone. */
|
||||
function waitForExit(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an out-of-process ACP child for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
* Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`,
|
||||
* and drives one session: `initialize` → `newSession` → `prompt`. The accumulated
|
||||
* `agent_message_chunk` text is the result output; the prompt's terminal
|
||||
* `StopReason` maps to the stop reason. `result` never REJECTS on a child-level
|
||||
* failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per
|
||||
* the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the
|
||||
* subprocess and awaits its exit (quiescent teardown).
|
||||
*/
|
||||
export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun {
|
||||
const id = AgentId(randomUUID())
|
||||
|
||||
// Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP
|
||||
// response channel, stderr = INHERIT so the child's diagnostics surface on the
|
||||
// parent's stderr (no separate capture to drain — we don't fold child stderr
|
||||
// into the result; the seam reports only output + stop reason).
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: spec.cwd,
|
||||
env: buildChildEnv(spec.env),
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
})
|
||||
// A spawn-level failure (e.g. ENOENT for a bad command) is emitted as an
|
||||
// `error` event, NOT a thrown exception — without a listener Node treats it as
|
||||
// an unhandled error and crashes the parent. Capture it into a promise the
|
||||
// result path races, so a bad command settles `error` like any child failure.
|
||||
const spawnFailed = new Promise<Error>((resolve) => {
|
||||
child.once('error', (err) => { resolve(err) })
|
||||
})
|
||||
|
||||
// Accumulate the child's streamed assistant text — the SubagentResult output.
|
||||
const output: string[] = []
|
||||
// `cancelled` records that a cancel was requested (signal or cancel()), so a
|
||||
// run torn down before the prompt resolves settles `aborted` rather than the
|
||||
// generic error mapping.
|
||||
let cancelled = false
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update
|
||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
output.push(acpContentText(update.content))
|
||||
}
|
||||
// Other updates (thoughts, tool calls, plans) are consumed but not
|
||||
// surfaced in this cut — the subagent returns only its final answer.
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Auto-answer by the configured policy. `allow` selects the first
|
||||
// allow-shaped option the child offered; if it offered none (or we
|
||||
// reject), answer `cancelled` so the child does not proceed.
|
||||
if (spec.permission === 'allow') {
|
||||
const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always')
|
||||
if (allow !== undefined) {
|
||||
return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } })
|
||||
}
|
||||
}
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
})
|
||||
|
||||
const conn = new ClientSideConnection(
|
||||
makeClient,
|
||||
ndJsonStream(
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
),
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
const requestCancel = (): void => {
|
||||
cancelled = true
|
||||
// Best-effort: tell the child to cancel the in-flight turn. Swallows a
|
||||
// rejection — the session may not exist yet, or the pipe may be gone; the
|
||||
// dispose path kills the process regardless. If the session has NOT been
|
||||
// created yet (cancel raced ahead of `newSession`), the `cancelled` flag
|
||||
// alone carries it: the result path re-checks the flag after each await and
|
||||
// settles `aborted` without running the prompt. The `.catch` swallow is
|
||||
// defensive for a narrow transport race (child gone mid-send) — v8-ignored
|
||||
// because dispose kills the process regardless, so it can't be hit in tests.
|
||||
/* v8 ignore next */
|
||||
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
|
||||
}
|
||||
const onAbort = (): void => { requestCancel() }
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
// The accumulated child text as harness ContentBlocks (empty array when the
|
||||
// child streamed nothing). Read at every return so a partial answer survives
|
||||
// a later cancel/error.
|
||||
const collectOutput = (): ContentBlock[] => {
|
||||
const text = output.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
try {
|
||||
// An already-aborted request never runs the child.
|
||||
if (request.signal?.aborted) {
|
||||
cancelled = true
|
||||
return { output: [], stopReason: 'aborted' }
|
||||
}
|
||||
// Race the ACP drive against a spawn failure: a bad command never speaks
|
||||
// ACP, so `initialize` would hang forever — the spawn `error` event is the
|
||||
// only signal, and a rejected race settles the run `error` via the catch.
|
||||
const driveAcp = async (): Promise<SubagentResult> => {
|
||||
await conn.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
// Advertise NO optional client capabilities (no fs, no terminal): the
|
||||
// child self-serves in its own process.
|
||||
clientCapabilities: {},
|
||||
})
|
||||
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
|
||||
sessionId = session.sessionId
|
||||
// A cancel that raced ahead of `newSession` set `cancelled` but could not
|
||||
// send `session/cancel` (no session id yet). Honor it here: settle
|
||||
// `aborted` without ever issuing the prompt, rather than running the child
|
||||
// to completion and ignoring the cancel.
|
||||
if (cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) })
|
||||
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
|
||||
}
|
||||
return await Promise.race([
|
||||
driveAcp(),
|
||||
spawnFailed.then((err): SubagentResult => { throw err }),
|
||||
])
|
||||
} catch {
|
||||
// The seam contract: result resolves (never rejects) on a child-level
|
||||
// failure. A spawn/transport/RPC error becomes an error/aborted result —
|
||||
// `aborted` if a cancel was requested (the failure is the cancellation
|
||||
// surfacing as a torn pipe / rejected RPC), else a genuine `error`.
|
||||
return { output: collectOutput(), stopReason: cancelled ? 'aborted' : 'error' }
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
result,
|
||||
cancel(_reason?: string): void {
|
||||
requestCancel()
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
// Kill the subprocess and AWAIT its exit (quiescent teardown — dispose
|
||||
// must reach quiescence, not merely request it). SIGTERM first; the child
|
||||
// is our own short-lived ACP agent, so a graceful term is enough. Guard
|
||||
// the kill: the process may already be gone.
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
await waitForExit(child)
|
||||
},
|
||||
}
|
||||
}
|
||||
150
packages/subagent/subagent-acp/tests/mock-acp-server.ts
Normal file
150
packages/subagent/subagent-acp/tests/mock-acp-server.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* A minimal mock ACP AGENT, run as a subprocess, for the keyless
|
||||
* `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is
|
||||
* fully scripted by environment variables — no model, no network:
|
||||
*
|
||||
* - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`.
|
||||
* - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt`
|
||||
* (`end_turn` default, or `max_tokens`/`refusal`/…).
|
||||
* - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
|
||||
* a `session/cancel`), to exercise the client's cancel path.
|
||||
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
|
||||
* before answering, to exercise the client's auto-answer.
|
||||
* - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
|
||||
* handler is in flight (it has streamed its chunk). A test
|
||||
* polls for this file to cancel on a CONDITION rather than
|
||||
* an arbitrary timeout (subprocess cold-start is variable).
|
||||
*
|
||||
* It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
|
||||
* child process the ACP backend drives. Kept as a `.ts` run under tsx by the
|
||||
* spec (which passes its own tsconfig), mirroring how the snapshot harness boots
|
||||
* the real example.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent,
|
||||
type CancelNotification,
|
||||
type AuthenticateRequest,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
const TEXT = process.env.MOCK_TEXT ?? 'mock child answer'
|
||||
const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
|
||||
const HANG = process.env.MOCK_HANG === '1'
|
||||
const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
|
||||
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
|
||||
const THOUGHT = process.env.MOCK_THOUGHT === '1'
|
||||
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
|
||||
const READY_FILE = process.env.MOCK_READY_FILE
|
||||
// When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks
|
||||
// until GO appears — letting a test cancel mid-newSession deterministically.
|
||||
const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && process.env.MOCK_NEWSESSION_GO !== undefined
|
||||
? { ready: process.env.MOCK_NEWSESSION_READY, go: process.env.MOCK_NEWSESSION_GO }
|
||||
: undefined
|
||||
|
||||
function makeAgent(conn: AgentSideConnection): Agent {
|
||||
// Pending cancel resolver for the HANG path: a `session/cancel` resolves the
|
||||
// prompt with `cancelled`.
|
||||
let resolveCancel: ((reason: StopReason) => void) | undefined
|
||||
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
return Promise.resolve({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } },
|
||||
authMethods: [],
|
||||
})
|
||||
},
|
||||
async newSession(_params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
// Optionally signal "newSession reached" and block until released, so a
|
||||
// test can cancel DURING newSession (the early-cancel race window) on a
|
||||
// condition rather than a timeout.
|
||||
if (NEWSESSION_GATE !== undefined) {
|
||||
writeFileSync(NEWSESSION_GATE.ready, 'at-newSession')
|
||||
while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
return { sessionId: randomUUID() }
|
||||
},
|
||||
authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||
// No auth methods advertised; nothing to do.
|
||||
return Promise.resolve()
|
||||
},
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
if (WANT_PERMISSION) {
|
||||
// Ask the client to approve before answering; honor its decision. Under
|
||||
// MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy
|
||||
// client finds no allow option and must fall back to cancelled.
|
||||
const options = NO_ALLOW
|
||||
? [{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const }]
|
||||
: [
|
||||
{ optionId: 'yes', name: 'Allow', kind: 'allow_once' as const },
|
||||
{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const },
|
||||
]
|
||||
const decision = await conn.requestPermission({
|
||||
sessionId: params.sessionId,
|
||||
toolCall: { toolCallId: 'mock-call', title: 'mock side effect' },
|
||||
options,
|
||||
})
|
||||
if (decision.outcome.outcome === 'cancelled') {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
}
|
||||
// Optionally emit a NON-message update first (a thought), so the client's
|
||||
// sessionUpdate sees an update it must consume-but-not-accumulate.
|
||||
if (THOUGHT) {
|
||||
await conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } },
|
||||
})
|
||||
}
|
||||
// Stream the canned assistant text as one chunk.
|
||||
await conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: TEXT } },
|
||||
})
|
||||
// Signal "prompt is in flight" by touching the readiness file, so a test
|
||||
// can wait on a CONDITION (file exists) rather than an arbitrary timeout
|
||||
// before cancelling — deterministic regardless of subprocess cold-start.
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ready')
|
||||
if (HANG) {
|
||||
// Never resolve on our own: wait for session/cancel to settle us.
|
||||
return new Promise<PromptResponse>((resolve) => {
|
||||
resolveCancel = (reason) => { resolve({ stopReason: reason }) }
|
||||
})
|
||||
}
|
||||
return { stopReason: STOP }
|
||||
},
|
||||
cancel(_params: CancelNotification): Promise<void> {
|
||||
if (CRASH_ON_CANCEL) {
|
||||
// Exit hard instead of answering — tears the ACP pipe, so the client's
|
||||
// pending prompt REJECTS (exercises the backend's catch-while-cancelled
|
||||
// path: a transport failure after a cancel settles `aborted`).
|
||||
process.exit(1)
|
||||
}
|
||||
resolveCancel?.('cancelled')
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
new AgentSideConnection(
|
||||
makeAgent,
|
||||
ndJsonStream(
|
||||
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
|
||||
),
|
||||
)
|
||||
110
packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
Normal file
110
packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as acp from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP
|
||||
* server. The backend spawns the real `acp-agent` example as a child PROCESS,
|
||||
* speaks ACP to it over stdio, and the child runs the REAL model in its own
|
||||
* process to answer a prompt. We verify the child's real answer comes back
|
||||
* through the seam — the "talk to our own process" smoke the design called for.
|
||||
* Key-gated (self-skips without DEEPSEEK_API_KEY).
|
||||
*
|
||||
* This is the out-of-process analogue of the in-process spawn e2e: there a
|
||||
* parent agent on the same context drove a child; here the child is a separate
|
||||
* process reached over ACP, proving the seam generalizes across the boundary.
|
||||
*/
|
||||
|
||||
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
|
||||
const binScript = fileURLToPath(new URL('../../../ui/acp-agent/src/bin.ts', import.meta.url))
|
||||
const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** The ACP backend ignores the parent, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive our own acp-agent)', () => {
|
||||
it('drives the real acp-agent example process to answer a prompt', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, exampleConfig],
|
||||
cwd: workdir,
|
||||
permission: 'reject',
|
||||
// The child harness needs the key to reach the model; forward it
|
||||
// explicitly (buildChildEnv scrubs ambient creds but keeps these extras).
|
||||
env: {
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
})
|
||||
|
||||
const run = ctx.subagents.start('acp', {
|
||||
prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }],
|
||||
parent: fakeParent,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
// The real child process completed its turn and streamed a real answer back
|
||||
// across the ACP boundary.
|
||||
expect(result.stopReason).toBe('completed')
|
||||
const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('')
|
||||
expect(text.length).toBeGreaterThan(0)
|
||||
expect(text.toUpperCase()).toContain('PONG')
|
||||
}, 180_000)
|
||||
|
||||
it('drives the child to do real file work via its own bash tool', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, exampleConfig],
|
||||
cwd: workdir,
|
||||
// The child needs to act (run bash), so approve its permission prompts.
|
||||
permission: 'allow',
|
||||
env: {
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
})
|
||||
|
||||
const run = ctx.subagents.start('acp', {
|
||||
prompt: [{ type: 'text', text:
|
||||
'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt '
|
||||
+ 'in the current directory. Then reply DONE.' }],
|
||||
parent: fakeParent,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// Verify the WORLD: the child process actually wrote the file in its cwd.
|
||||
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
|
||||
expect(proof).toContain('ACP_CHILD_WAS_HERE')
|
||||
}, 180_000)
|
||||
})
|
||||
315
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
Normal file
315
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as acp from '../src/index.ts'
|
||||
import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, toAcpPrompt } from '../src/run.ts'
|
||||
|
||||
/**
|
||||
* Keyless integration tests for the ACP subagent backend. Each spawns a REAL
|
||||
* subprocess — the scripted mock ACP server (tests/mock-acp-server.ts) — and
|
||||
* drives it through the REAL backend over real ACP JSON-RPC stdio, so the
|
||||
* connection setup, the client callbacks, the prompt round-trip, the stop-reason
|
||||
* mapping, cancellation, and quiescent disposal are all exercised end to end.
|
||||
* No model, no key.
|
||||
*/
|
||||
|
||||
const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
|
||||
interface SetupEnv {
|
||||
/** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the ACP backend pointed at the mock server, scripted by `mockEnv`.
|
||||
* `permission` selects the backend's auto-answer policy.
|
||||
*/
|
||||
async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
permission,
|
||||
// The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets
|
||||
// tsx resolve @deepseek-ai/* from a child cwd outside the repo.
|
||||
env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
function text(blocks: { type: string; text?: string }[]): string {
|
||||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until `file` exists (the mock touches it once its prompt is in flight),
|
||||
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
|
||||
* subprocess cold-start under tsx is variable, and a fixed sleep both flakes and
|
||||
* slows the suite. Fails loud if the child never signals readiness.
|
||||
*/
|
||||
async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!existsSync(file)) {
|
||||
if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`)
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('acpStopReason', () => {
|
||||
it('maps each ACP stop reason to the harness vocabulary', () => {
|
||||
expect(acpStopReason('end_turn')).toBe('completed')
|
||||
expect(acpStopReason('max_tokens')).toBe('max-tokens')
|
||||
expect(acpStopReason('refusal')).toBe('refusal')
|
||||
expect(acpStopReason('cancelled')).toBe('aborted')
|
||||
expect(acpStopReason('max_turn_requests')).toBe('error')
|
||||
})
|
||||
|
||||
it('treats an unknown terminal reason as an error', () => {
|
||||
expect(acpStopReason('something-new' as never)).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('acpContentText / toAcpPrompt', () => {
|
||||
it('extracts text from a text content block, empty for non-text', () => {
|
||||
expect(acpContentText({ type: 'text', text: 'hi' })).toBe('hi')
|
||||
// A non-text ACP content block (e.g. an image) contributes no text.
|
||||
expect(acpContentText({ type: 'image', data: 'x', mimeType: 'image/png' })).toBe('')
|
||||
})
|
||||
|
||||
it('keeps text prompt blocks and drops non-text ones', () => {
|
||||
expect(toAcpPrompt([{ type: 'text', text: 'a' }])).toEqual([{ type: 'text', text: 'a' }])
|
||||
// A non-text harness block (e.g. reasoning) is dropped from the ACP prompt.
|
||||
expect(toAcpPrompt([{ type: 'text', text: 'a' }, { type: 'reasoning', text: 'think' }]))
|
||||
.toEqual([{ type: 'text', text: 'a' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildChildEnv', () => {
|
||||
it('drops credential-shaped ambient vars but keeps the explicit extras', () => {
|
||||
process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me'
|
||||
try {
|
||||
const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' })
|
||||
// The credential-shaped ambient var is scrubbed.
|
||||
expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined()
|
||||
// The explicitly-supplied key survives (an opt-in for the child's creds).
|
||||
expect(env.DEEPSEEK_API_KEY).toBe('explicit')
|
||||
// A normal ambient var is forwarded.
|
||||
expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false)
|
||||
expect(env.PATH).toBe(process.env.PATH)
|
||||
} finally {
|
||||
delete process.env.DSH_ACP_TEST_SECRET_TOKEN
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-subagent-acp', () => {
|
||||
it('drives a child process to completion and returns its streamed output', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'do X' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('hello from acp child')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('maps a max_tokens stop reason', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('maps a refusal stop reason', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('refusal')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('cancelling a running child settles aborted (session/cancel via run.cancel)', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
|
||||
const readyFile = join(tmp, 'ready')
|
||||
try {
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
// Wait until the child's prompt is in flight (condition, not a sleep),
|
||||
// then cancel — so we exercise the mid-run session/cancel path.
|
||||
await waitForFile(readyFile)
|
||||
run.cancel('test')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('settles aborted without running the child when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const ctx = await setup({ MOCK_TEXT: 'never seen' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {
|
||||
// Gate the child at newSession: it signals `ready` and blocks until `go`.
|
||||
// We cancel WHILE newSession is pending (sessionId still undefined, so the
|
||||
// backend cannot send session/cancel) — the `cancelled` flag alone must
|
||||
// settle the run aborted after newSession resolves, never issuing the prompt.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-early-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const go = join(tmp, 'go')
|
||||
try {
|
||||
const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
await waitForFile(ready) // newSession is now in flight, sessionId undefined
|
||||
run.cancel('early') // sets cancelled; cannot send session/cancel yet
|
||||
writeFileSync(go, 'go') // let newSession resolve
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('bridges the request signal to a session/cancel mid-run', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-'))
|
||||
const readyFile = join(tmp, 'ready')
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal })
|
||||
await waitForFile(readyFile)
|
||||
controller.abort()
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
// The child asked permission, the backend rejected, the child returned cancelled.
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('auto-approves a permission prompt under the allow policy', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow')
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('approved answer')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('falls back to cancelled under the allow policy when the child offers no allow option', async () => {
|
||||
// The child asks permission but offers ONLY reject-shaped options, so an
|
||||
// allow-policy client finds nothing to select and must answer cancelled.
|
||||
const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow')
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('consumes a non-message update (a thought) without adding it to the output', async () => {
|
||||
// The child streams an agent_thought_chunk before its answer; the backend
|
||||
// must consume it but NOT include it in the result output.
|
||||
const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// Only the message text, NOT the thought.
|
||||
expect(text(result.output)).toBe('final answer')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('resolves error (not reject) when the spawn command does not exist', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: '/nonexistent/acp-agent-binary',
|
||||
args: [],
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
// The seam contract: a child-level failure resolves error, never rejects.
|
||||
expect(result.stopReason).toBe('error')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
|
||||
// The child hangs, we cancel, and instead of answering the child exits hard
|
||||
// — the pending prompt RPC rejects. With a cancel already requested, the
|
||||
// backend's catch path must settle `aborted` (the failure is the cancel
|
||||
// surfacing as a torn pipe), not `error`.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
try {
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
await waitForFile(ready)
|
||||
run.cancel('crash it')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('advertises no start-time capabilities (out-of-process child)', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = ctx.subagents.getProvider('acp')!
|
||||
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} })
|
||||
expect(ctx.subagents.list()).toEqual(['acp'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in acp).toBe(false)
|
||||
expect(acp.name).toBe('subagent-acp')
|
||||
expect(acp.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(acp) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(acp)
|
||||
expect(unwrapped.name).toBe('subagent-acp')
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
30
packages/subagent/subagent-acp/tsconfig.json
Normal file
30
packages/subagent/subagent-acp/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,6 +34,6 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
|
||||
|
||||
## Scope (first cut)
|
||||
|
||||
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md).
|
||||
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
|
||||
See `src/types.ts` for the full contracts.
|
||||
|
||||
@@ -16,4 +16,4 @@ This plugin binds to **exactly one** provider (`Config.provider`). The model see
|
||||
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
|
||||
Reference in New Issue
Block a user