refactor(subagent): unify async readiness and cancellation

This commit is contained in:
Tianyi Cui
2026-07-12 22:41:59 +08:00
parent 02ca71db57
commit bb3f6bd736
49 changed files with 1350 additions and 4147 deletions

View File

@@ -1,32 +1,31 @@
# @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.
The ACP provider runs each subagent in a fresh subprocess and drives it as an Agent Client Protocol client. It is the out-of-process alternative to spawn and fork: the child has its own runtime, session, model configuration, and tools.
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".
## Start and ownership
## What it does
`start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped.
`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. `run.started` resolves after `newSession` publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. 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.
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
**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).
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented.
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`.
## Capabilities and context
## Config
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh and ignores `request.parent` beyond the seam's required attribution field.
| 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 | process 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. |
| `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. |
| `disposeGraceMs` | number | `3000` | Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. |
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `providerName` | `acp` | Registry name on `ctx.subagents`. |
| `command` | required | Executable spawned for each run. |
| `args` | `[]` | Command arguments. |
| `cwd` | process cwd | Child process and ACP session working directory. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. |
| `disposeGraceMs` | `3000` | Grace after SIGTERM before SIGKILL. |
```yaml
- id: subagent-acp
@@ -40,32 +39,20 @@ Unlike the in-process backends, the child does NOT share this cordis context —
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
```
## StopReason mapping
## Stop-reason mapping
ACP `StopReason` → harness `SubagentStopReason`:
| ACP | harness |
| 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` |
| `max_turn_requests` or 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.
## Process boundary
## Environment scrub
The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The child env is built by [`buildChildEnv` from `@deepseek-ai/dsh-subagent-subprocess`](../subagent-subprocess/README.md) — the ambient env minus credential-shaped vars, with `config.env` layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN `DEEPSEEK_API_KEY` is supplied deliberately via `config.env` and survives.
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
## 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)).
Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.

View File

@@ -180,35 +180,19 @@ function toError(value: unknown): Error {
* 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).
* @param request - the start request; the driver consumes `prompt` and `signal`
* (an already-aborted signal yields an inert `aborted` run with no spawn).
* failure after publication resolves with `stopReason: 'error'`. A spawn,
* initialize, new-session, or pre-publication cancellation failure instead
* rejects only after the process has been reaped. `dispose()` requests ACP
* cancellation, then kills and reaps the subprocess.
* @param request - the start request; its signal is the cancellation channel.
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
* policy, dispose graces, and the optional error sink.
* @returns the live run handle for the child subprocess.
* @returns the ready run handle for the child subprocess.
*/
export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun {
export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
const id = AgentId(randomUUID())
// A request already aborted before it starts never spawns the child at all —
// return an inert run that settled `aborted`, rather than launching the
// configured binary just to tear it down. `dispose`/`cancel` are no-ops.
if (request.signal?.aborted) {
const started = Promise.reject(new Error('subagent request was aborted before the ACP child started'))
// The result is derived from the same boundary so the readiness rejection
// is observed even when this provider is driven directly rather than
// through SubagentService.
const result: Promise<SubagentResult> = started.catch(() => ({ output: [], stopReason: 'aborted' }))
return {
id,
started,
result,
cancel(_reason?: string): void { /* nothing was started */ },
dispose(): Promise<void> { return Promise.resolve() },
}
}
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
// Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP
// response channel, stderr = INHERIT so the child's diagnostics surface on the
@@ -225,9 +209,18 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// `error` like any child failure.
const spawnFailed = spawnFailure(child)
// One memoized quiescence transaction is shared by startup rollback and the
// published run's disposer. Once start fulfills, only the holder can invoke
// it; before fulfillment the provider invokes it on every failure path.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
}))
// 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
// `cancelled` records that the required signal or disposal requested cancel, so a
// run torn down before the prompt resolves settles `aborted` rather than the
// generic error mapping. Held on a mutable object so the async closures that
// set it (the abort listener) and the IIFE that reads it don't fight TS's
@@ -271,7 +264,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// Resolves when a cancel is requested, so `result` can settle `aborted` even
// if the child never cooperates with `session/cancel` (it ignores the notify,
// or the prompt wedges). The result path races this against the ACP drive: the
// FIRST to settle wins, so `cancel()` always honors the contract (`result`
// FIRST to settle wins, so signal/dispose cancellation always honors the contract (`result`
// settles `aborted`) without waiting on a non-cooperative child. `dispose`
// still kills the process and reaps it; this only unblocks `result`. The
// executor runs synchronously, so `signalCancelSettled` is assigned before the
@@ -279,6 +272,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
let signalCancelSettled!: () => void
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
const requestCancel = (): void => {
if (flags.cancelled) return
flags.cancelled = true
signalCancelSettled()
// Best-effort: tell the child to cancel the in-flight turn. Swallows a
@@ -293,7 +287,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
}
const onAbort = (): void => { requestCancel() }
request.signal?.addEventListener('abort', onAbort, { once: true })
request.signal.addEventListener('abort', onAbort, { once: true })
// The accumulated child text as harness ContentBlocks (empty array when the
// child streamed nothing). Read at every return so a partial answer survives
@@ -303,47 +297,41 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
return text.length > 0 ? [{ type: 'text', text }] : []
}
// A provider is "started" only once the remote child has completed ACP
// initialization and published a session. SubagentService gates its
// `subagent/start` notification on this boundary, just as the in-process
// provider gates it on local Agent publication. Failure or cancellation
// before this point rejects readiness and therefore produces no paired
// lifecycle events for a child that never became live.
const started: Promise<void> = Promise.race([
(async (): Promise<void> => {
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
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
// Establish the remote session before publishing a handle. Any failure owns
// the still-private process and therefore reaps it before rejecting.
try {
await Promise.race([
(async (): Promise<void> => {
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
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
await disposeProcess()
if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
throw toError(error)
}
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Readiness is the initialize → newSession phase above. Awaiting the SAME
// promise immediately observes its rejection even without the service,
// and guarantees the prompt phase never starts before the provider can
// truthfully announce a live child.
await started
// Race two post-start outcomes, first to settle wins:
// Race two post-publication outcomes, first to settle wins:
// - prompt: the normal remote turn;
// - cancelSettled: a cancel was requested — settle `aborted` immediately
// rather than waiting on a child that may ignore `session/cancel` or
// wedge the prompt (the `cancel()` contract: `result` settles `aborted`).
// A spawn error can only precede readiness and is already one arm of
// `started`; after `newSession` succeeds, transport/process failure rejects
// the in-flight prompt RPC through the connection.
// wedge the prompt (`result` settles `aborted`). After `newSession`
// succeeds, transport/process failure rejects the in-flight prompt RPC.
const prompt = async (): Promise<SubagentResult> => {
// `started` cannot fulfill without assigning the session id; the cast
// records that local invariant without an unreachable defensive arm.
// The startup phase cannot fulfill without assigning the session id.
const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) })
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
}
@@ -354,9 +342,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
} catch (error: unknown) {
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// The seam contract: result resolves (never rejects) on a child-level
// failure. A cancellation is recognized by the flag above even when it
// wins during readiness; every other rejection is a genuine child-level
// error — initialize/newSession/prompt transport/RPC failure or ENOENT.
// failure. Startup failures were already rejected before publication;
// every rejection here is a prompt transport/RPC failure.
// Flatten to `error` and surface the original via onError so a real fault
// is preserved rather than silently lost.
try {
@@ -367,18 +354,19 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// The child-level failure being reported still settles as `error`.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposal: Promise<void> | undefined
return {
id,
started,
result,
cancel(_reason?: string): void {
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
},
async dispose(): Promise<void> {
request.signal?.removeEventListener('abort', onAbort)
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM →
// SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the
// one that matters: our acp-agent has NO SIGTERM handler in a normal
@@ -388,10 +376,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// turn/end BEFORE that post-turn flush lands, so the child still has
// durable work owed when dispose runs (hence the wide EOF grace; see
// DEFAULT_DISPOSE_EOF_GRACE_MS).
await disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
})
disposal = disposeProcess()
return disposal
},
}
}

View File

@@ -68,6 +68,7 @@ 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 CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1'
const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
const READY_FILE = process.env.MOCK_READY_FILE
const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
@@ -105,6 +106,7 @@ function makeAgent(conn: AgentSideConnection): Agent {
return Promise.resolve()
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
if (CRASH_ON_PROMPT) process.exit(1)
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
@@ -225,4 +227,3 @@ if (process.env.MOCK_IGNORE_EOF === '1') {
setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000)
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed')
}

View File

@@ -60,9 +60,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
},
})
const run = ctx.subagents.start('acp', {
const run = await ctx.subagents.start('acp', {
prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }],
parent: fakeParent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()
@@ -93,11 +94,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
},
})
const run = ctx.subagents.start('acp', {
const run = await 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,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()

View File

@@ -27,6 +27,10 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
function request(text = 'p', signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
}
interface SetupEnv {
/** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */
[key: string]: string
@@ -119,7 +123,7 @@ describe('buildChildEnv', () => {
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 run = await ctx.subagents.start('acp', request('do X'))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('hello from acp child')
@@ -128,7 +132,7 @@ describe('dsh-subagent-acp', () => {
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 run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
@@ -136,22 +140,23 @@ describe('dsh-subagent-acp', () => {
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 run = await ctx.subagents.start('acp', request())
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 () => {
it('aborting the required signal cancels a running child', 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 })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
// 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')
controller.abort('test')
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -160,7 +165,7 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted WITHOUT spawning the child when the signal is already aborted', async () => {
it('rejects WITHOUT spawning the child when the signal is already aborted', async () => {
// A pre-aborted request must not even launch the configured binary. Point
// the command at one that would create a sentinel file if it ever ran, and
// assert the sentinel never appears.
@@ -169,17 +174,11 @@ describe('dsh-subagent-acp', () => {
try {
const controller = new AbortController()
controller.abort()
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal },
await expect(startAcpRun(
request('p', controller.signal),
// `touch <sentinel>` — runs only if the process is actually spawned.
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
)
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
// cancel/dispose on the inert run are safe no-ops.
run.cancel('noop')
await run.dispose()
)).rejects.toThrow('aborted before the ACP child started')
// The binary was never launched — no sentinel.
expect(existsSync(sentinel)).toBe(false)
} finally {
@@ -206,7 +205,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 150,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
// sleep) — otherwise SIGTERM races the trap install and the default handler
// terminates the child, never exercising the escalation.
@@ -253,7 +252,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 2000,
disposeGraceMs: 50,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
// Wait until the child is fully booted with its prompt in flight (its ACP
// stdin reader is attached), so dispose's stdin EOF reaches a live child.
await waitForFile(ready)
@@ -290,7 +289,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 2000,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
await waitForFile(ready)
// Bound it so a hang fails loud rather than stalling the suite.
await expect(Promise.race([
@@ -305,7 +304,7 @@ describe('dsh-subagent-acp', () => {
}
})
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {
it('rejects after cleanup when the signal aborts during newSession', 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
@@ -315,14 +314,12 @@ describe('dsh-subagent-acp', () => {
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 })
const controller = new AbortController()
const starting = ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready) // newSession is now in flight, sessionId undefined
run.cancel('early') // sets cancelled; cannot send session/cancel yet
controller.abort('early')
writeFileSync(go, 'go') // let newSession resolve
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
await expect(starting).rejects.toThrow('aborted before the ACP child started')
} finally {
rmSync(tmp, { recursive: true, force: true })
}
@@ -334,7 +331,7 @@ describe('dsh-subagent-acp', () => {
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 })
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(readyFile)
controller.abort()
const result = await run.result
@@ -347,7 +344,7 @@ describe('dsh-subagent-acp', () => {
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 run = await ctx.subagents.start('acp', request())
const result = await run.result
// The child asked permission, the backend rejected, the child returned cancelled.
expect(result.stopReason).toBe('aborted')
@@ -356,7 +353,7 @@ describe('dsh-subagent-acp', () => {
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 run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('approved answer')
@@ -367,7 +364,7 @@ describe('dsh-subagent-acp', () => {
// 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 run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -377,7 +374,7 @@ describe('dsh-subagent-acp', () => {
// 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 run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('completed')
// Only the message text, NOT the thought.
@@ -385,18 +382,11 @@ describe('dsh-subagent-acp', () => {
await run.dispose()
})
it('resolves error (not reject) when the spawn command does not exist', async () => {
// Direct startAcpRun with NO onError sink — the catch must still flatten the
// spawn failure to `error` (the onError call is optional, covering the
// absent-sink branch).
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
it('rejects a spawn failure after provider-owned cleanup', async () => {
await expect(startAcpRun(
request(),
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
)
const result = await run.result
// The seam contract: a child-level failure resolves error, never rejects.
expect(result.stopReason).toBe('error')
await run.dispose()
)).rejects.toThrow()
})
it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
@@ -418,7 +408,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 150,
})
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
await waitForFile(ready)
await expect(Promise.race([
run.dispose(),
@@ -440,7 +430,7 @@ describe('dsh-subagent-acp', () => {
}
})
it('resolves error via the provider (real load path) when the command does not exist', async () => {
it('rejects a startup failure via the provider load path', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(acp, {
@@ -450,26 +440,23 @@ describe('dsh-subagent-acp', () => {
permission: 'reject',
env: {},
})
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const result = await run.result
expect(result.stopReason).toBe('error')
await run.dispose()
await expect(ctx.subagents.start('acp', request())).rejects.toThrow()
})
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
// The seam forbids `result` rejecting, so a child-level failure is flattened
// to a stop reason — onError must still surface the original error so a real
// fault is logged, not swallowed. A nonexistent command triggers the spawn
// failure path; the spy records the error + the chosen stop reason.
// fault is logged, not swallowed. The child exits after its session is
// published but while prompt is in flight.
const errors: { message: string; stopReason: string }[] = []
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
const run = await startAcpRun(
request(),
{
command: '/nonexistent/acp-agent-binary',
args: [],
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {},
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
@@ -487,14 +474,14 @@ describe('dsh-subagent-acp', () => {
// onError is a caller-supplied callback boundary: its own exception must be
// contained, or it would reject `result` and break the seam's "result never
// rejects" contract that the flattening above exists to uphold.
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
const run = await startAcpRun(
request(),
{
command: '/nonexistent/acp-agent-binary',
args: [],
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {},
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
onError: () => { throw new Error('sink boom') },
@@ -514,9 +501,10 @@ describe('dsh-subagent-acp', () => {
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 })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready)
run.cancel('crash it')
controller.abort('crash it')
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -525,8 +513,8 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => {
// The contract: run.cancel() → result settles `aborted`. A child that hangs
it('settles aborted on signal even when the child IGNORES session/cancel', async () => {
// The signal contract requires `result` to settle `aborted`. A child that hangs
// its prompt AND ignores session/cancel must not wedge the parent — the
// backend's own cancel-settle path resolves `aborted` without the child's
// cooperation, and dispose() still reaps the process.
@@ -534,9 +522,10 @@ describe('dsh-subagent-acp', () => {
const ready = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready)
run.cancel('test')
controller.abort('test')
// Bound it: a regression (cancel only notifies the child, which ignores it)
// would hang result forever — fail loud instead of stalling the suite.
const result = await Promise.race([