diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 4a43d0eef9..0058e9116f 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 93bb556ba6..d04b0bc2b5 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -214,6 +214,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise }, }) const active = launched + await active.spawned const { client } = active for (const step of input.steps) { diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 975b1bb852..3508b02ec5 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -53,6 +53,8 @@ export interface AcpTestLaunchOptions { export interface LaunchedAcpTestAgent { /** The child process, exposed for process-level assertions. */ child: ChildProcessWithoutNullStreams + /** Resolve when the OS spawns the child; reject with its asynchronous spawn failure. */ + spawned: Promise /** The SDK connection backed by the child's stdio. */ client: ClientSideConnection /** Session updates in receive order. */ @@ -90,6 +92,18 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe stdio: ['pipe', 'pipe', 'pipe'], }, ) + // A spawn-level failure is an asynchronous `error` event. Observe it in the + // same tick as spawn so a missing cwd or OS rejection cannot crash the test + // runner, then make startup and shutdown surface the original error. + const childFailure = new Promise(resolve => child.once('error', resolve)) + const spawned = Promise.race([ + new Promise(resolve => child.once('spawn', resolve)), + childFailure.then((error): never => { throw error }), + ]) + // `spawned` is public and close() also awaits it, but a caller may ignore both. + // Keep that misuse from turning the already-observed child error into an + // unhandled promise rejection. + void spawned.catch(() => undefined) const stderrChunks: string[] = [] child.stderr.setEncoding('utf8') @@ -141,16 +155,22 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe return { child, + spawned, client, updates, rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), stderr: () => stderrChunks.join(''), waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), async close(signal?: NodeJS.Signals): Promise { + await spawned if (child.exitCode !== null || child.signalCode !== null) return if (signal === undefined) child.stdin.end() else child.kill(signal) - await waitForExit(child) + const failure = await Promise.race([ + waitForExit(child).then((): undefined => undefined), + childFailure, + ]) + if (failure !== undefined) throw failure }, } } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index db715db90c..69c02a407f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -40,6 +40,13 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('surfaces an asynchronous child spawn failure through startup and close', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' }) const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-')) @@ -72,7 +79,11 @@ describe('runScenario', () => { // The minimal shape needs no environment or config override. const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - await minimal.close() + const childFailure = new Error('child process failed') + const exited = new Promise(resolve => minimal.child.once('exit', () => { resolve() })) + minimal.child.emit('error', childFailure) + await expect(minimal.close('SIGKILL')).rejects.toBe(childFailure) + await exited }) it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {