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:
Tianyi Cui
2026-06-22 10:47:02 +08:00
parent 413db68008
commit f393043b03
23 changed files with 1192 additions and 12 deletions

View File

@@ -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).

View 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)).

View 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"
}
}

View 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))
}

View 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)
},
}
}

View 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>,
),
)

View 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)
})

View 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')
})
})

View 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"
}
]
}

View File

@@ -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.

View File

@@ -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.