Merge PR #224 updates into prose cleanup
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent run driver.
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -77,9 +96,16 @@ export interface AcpRunSpec {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default grace for the child's EOF-driven quiesce on dispose (the `disposeEofGraceMs` config)
|
||||
* — the window for it to flush persistence and tear down its own nested subprocesses (which
|
||||
* may run their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a signal.
|
||||
* Default grace for the child's EOF-driven quiesce on dispose (the
|
||||
* `disposeEofGraceMs` config) — the window for it to flush persistence and tear
|
||||
* down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL`
|
||||
* escalation) before the parent escalates to a signal. Deliberately LARGER than
|
||||
* {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself
|
||||
* waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s
|
||||
* SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single
|
||||
* signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it
|
||||
* reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is
|
||||
* a standalone generous default, NOT derived from any child's internals.
|
||||
*/
|
||||
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
|
||||
|
||||
@@ -150,32 +176,23 @@ function toError(value: unknown): Error {
|
||||
/**
|
||||
* Start an out-of-process ACP child for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
* @param request - the start request; the driver consumes `prompt` and `signal`
|
||||
* (an already-aborted signal yields an inert `aborted` run with no spawn).
|
||||
* 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 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
|
||||
@@ -192,10 +209,23 @@ 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 run torn down
|
||||
// before the prompt resolves settles `aborted` rather than the generic error mapping.
|
||||
// `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
|
||||
// control-flow narrowing of a bare `let` (which would type the catch-time read
|
||||
// as always-`false`).
|
||||
const flags = { cancelled: false }
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
@@ -231,19 +261,33 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
// 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).
|
||||
// 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 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
|
||||
// Promise constructor returns (the `!` asserts the definite assignment).
|
||||
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.
|
||||
// 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 })
|
||||
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
|
||||
@@ -253,36 +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.
|
||||
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.
|
||||
await started
|
||||
|
||||
// Race two post-start 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`).
|
||||
// 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 (`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) }
|
||||
}
|
||||
@@ -291,8 +340,17 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
// A deterministic cancellation resolves `cancelSettled` before its
|
||||
// best-effort ACP cancel can reject the prompt. This fallback is only for
|
||||
// a process/pipe rejection already queued when the abort event fires; its
|
||||
// first-outcome ordering cannot be forced without a timing-dependent test.
|
||||
/* v8 ignore next */
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
// The seam contract: result resolves (never rejects) on a child-level failure.
|
||||
// The seam contract: result resolves (never rejects) on a child-level
|
||||
// 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 {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
@@ -301,24 +359,30 @@ 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).
|
||||
await disposeChildProcess(child, {
|
||||
disposeEofGraceMs: spec.disposeEofGraceMs,
|
||||
disposeGraceMs: spec.disposeGraceMs,
|
||||
})
|
||||
// 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
|
||||
// session — it tears down via the server bridge's connection-close path
|
||||
// (conn.closed → per-agent dispose → final session/flush), driven by the
|
||||
// stdin EOF, NOT by a signal — and a prompt response can resolve from a
|
||||
// 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).
|
||||
disposal = disposeProcess()
|
||||
return disposal
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,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
|
||||
@@ -68,6 +69,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
|
||||
|
||||
@@ -51,9 +51,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()
|
||||
@@ -84,11 +85,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()
|
||||
|
||||
@@ -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,16 +123,18 @@ 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')
|
||||
await run.dispose()
|
||||
const disposal = run.dispose()
|
||||
expect(run.dispose()).toBe(disposal)
|
||||
await disposal
|
||||
})
|
||||
|
||||
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 +142,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 +167,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 +176,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 {
|
||||
@@ -188,8 +189,9 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful term alone would
|
||||
// hang dispose forever.
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful
|
||||
// term alone would hang dispose forever. With a short grace, dispose must
|
||||
// escalate to SIGKILL and return once the process is actually gone.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
try {
|
||||
@@ -205,7 +207,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.
|
||||
@@ -223,8 +225,15 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
|
||||
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears down on
|
||||
// connection close, not on a signal) — and it has no SIGTERM handler.
|
||||
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
|
||||
// down on connection close, NOT on a signal) — and it has no SIGTERM handler.
|
||||
// Its EOF teardown can itself await a signal-trapping grandchild (a bash
|
||||
// subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window
|
||||
// must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value.
|
||||
// The mock models a flush that takes LONGER than the SIGTERM grace but well
|
||||
// under the EOF grace: it lands only because tier 1 waits eofGraceMs, not
|
||||
// graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the
|
||||
// round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.)
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const flushed = join(tmp, 'flushed')
|
||||
@@ -234,7 +243,10 @@ describe('dsh-subagent-acp', () => {
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live child.
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live
|
||||
// child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits
|
||||
// the 2000ms EOF grace; the marker lands iff the EOF tier honored its own
|
||||
// wider grace.
|
||||
env: {
|
||||
MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
|
||||
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
@@ -242,7 +254,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)
|
||||
@@ -256,9 +268,12 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window times out) but
|
||||
// exits cooperatively on SIGTERM must die on the SIGTERM tier — dispose returns there,
|
||||
// never reaching the SIGKILL tier.
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window
|
||||
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
|
||||
// — dispose returns there, never reaching the SIGKILL tier. The child touches
|
||||
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
|
||||
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
|
||||
// run and the marker would be absent — making this a GENUINE middle-tier guard.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const sigterm = join(tmp, 'sigterm')
|
||||
@@ -276,7 +291,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([
|
||||
@@ -291,21 +306,22 @@ 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
|
||||
// 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 })
|
||||
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 })
|
||||
}
|
||||
@@ -317,7 +333,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
|
||||
@@ -330,7 +346,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')
|
||||
@@ -339,7 +355,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')
|
||||
@@ -350,7 +366,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()
|
||||
@@ -360,7 +376,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.
|
||||
@@ -368,18 +384,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 () => {
|
||||
@@ -401,7 +410,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(),
|
||||
@@ -423,7 +432,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, {
|
||||
@@ -433,25 +442,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.
|
||||
// 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. 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 }) },
|
||||
@@ -465,18 +472,31 @@ describe('dsh-subagent-acp', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('logs a flattened child failure through the registered provider', async () => {
|
||||
const ctx = await setup({ MOCK_CRASH_ON_PROMPT: '1' })
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('subagent-acp "acp": child run failed (error):'),
|
||||
])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('resolves error (never rejects) even when the onError sink itself throws', async () => {
|
||||
// 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') },
|
||||
@@ -488,15 +508,18 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
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.
|
||||
// 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 })
|
||||
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()
|
||||
@@ -505,15 +528,19 @@ 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`.
|
||||
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.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
|
||||
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([
|
||||
|
||||
Reference in New Issue
Block a user