diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be50edf..48a1c38b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx ### Changes +- Runtime/embedding: expose optional process lifecycle hooks around ACP agent spawn, failure, and exit for durable host-owned launch tracking. Thanks @MertBasar0. + ### Breaking ### Fixes diff --git a/src/acp/client.ts b/src/acp/client.ts index 5c9b5d26..96083f27 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess, type ChildProcessByStdio } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { Readable, Writable } from "node:stream"; import { PROTOCOL_VERSION, @@ -74,6 +75,9 @@ import type { AcpClientOptions, AcpElicitationHandler, AcpElicitationMode, + AcpProcessLaunch, + AcpProcessLaunchScope, + AcpProcessStarted, NonInteractivePermissionPolicy, PermissionMode, PermissionStats, @@ -419,6 +423,18 @@ function snapshotPermissionPolicy( }; } +function snapshotProcessLaunchScope( + scope: AcpProcessLaunchScope | undefined, +): AcpProcessLaunchScope { + if (!scope || scope.kind === "client") { + return Object.freeze({ kind: "client" }); + } + if (scope.kind === "runtime-session") { + return Object.freeze({ kind: "runtime-session", sessionKey: scope.sessionKey }); + } + return Object.freeze({ kind: "runtime-probe", agent: scope.agent }); +} + type AuthSelection = { methodId: string; credential?: string; @@ -439,6 +455,7 @@ type AgentLaunchPlan = { type StartupFailureWatcher = { promise: Promise; dispose: () => void; + getError: () => AgentStartupError | undefined; }; type SessionUpdateSuppressionState = { @@ -815,12 +832,11 @@ export class AcpClient { const launch = await this.resolveAgentLaunchPlan(); this.logAgentLaunch(launch); await this.ensureLaunchSupport(launch); - const child = await this.spawnAgentProcess(launch); + const { child, process: startedProcess } = await this.spawnAgentProcess(launch); this.closing = false; - this.agentStartedAt = isoNow(); + this.agentStartedAt = startedProcess.startedAt; this.lastAgentExit = undefined; - this.lastKnownPid = child.pid ?? undefined; - this.attachAgentLifecycleObservers(child); + this.lastKnownPid = startedProcess.pid; const startupStderr: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { @@ -830,6 +846,17 @@ export class AcpClient { } process.stderr.write(chunk); }); + const startupFailure = this.createStartupFailureWatcher(child, startupStderr); + try { + await this.admitAndObserveSpawnedProcess(child, startedProcess); + const admissionExit = startupFailure.getError(); + if (admissionExit) { + throw admissionExit; + } + } catch (error) { + startupFailure.dispose(); + throw error; + } const input = Writable.toWeb(child.stdin); const output = Readable.toWeb(child.stdout) as ReadableStream; @@ -845,8 +872,6 @@ export class AcpClient { }, { once: true }, ); - const startupFailure = this.createStartupFailureWatcher(child, startupStderr); - await this.initializeAgentConnection({ child, connection, @@ -915,25 +940,76 @@ export class AcpClient { } } - private async spawnAgentProcess( - plan: AgentLaunchPlan, - ): Promise> { + private async spawnAgentProcess(plan: AgentLaunchPlan): Promise<{ + child: ChildProcessByStdio; + process: AcpProcessStarted; + }> { const spawnCommand = buildAgentSpawnCommand( plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env, ); - const spawnedChild = spawn(spawnCommand.command, spawnCommand.args, { - ...plan.spawnOptions, - windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, - }) as ChildProcessByStdio; + const launch: AcpProcessLaunch = Object.freeze({ + launchId: randomUUID(), + scope: snapshotProcessLaunchScope(this.options.processLaunchScope), + command: spawnCommand.command, + args: Object.freeze([...spawnCommand.args]), + cwd: this.options.cwd, + }); + await this.options.processLifecycle?.onBeforeSpawn?.(launch); + + let spawnedChild: ChildProcessByStdio; try { + spawnedChild = spawn(spawnCommand.command, spawnCommand.args, { + ...plan.spawnOptions, + windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, + }); await waitForSpawn(spawnedChild); } catch (error) { - throw new AgentSpawnError(this.options.agentCommand, error); + const spawnError = new AgentSpawnError(this.options.agentCommand, error); + this.notifyProcessSpawnFailure(launch, spawnError); + throw spawnError; + } + + const child = requireAgentStdio(spawnedChild); + const pid = child.pid; + if (pid === undefined) { + const spawnError = new AgentSpawnError( + this.options.agentCommand, + new Error("spawned agent process did not expose a PID"), + ); + this.notifyProcessSpawnFailure(launch, spawnError); + await this.terminateAgentProcess(child); + throw spawnError; + } + return { + child, + process: Object.freeze({ + ...launch, + pid, + startedAt: isoNow(), + }), + }; + } + + private async admitAndObserveSpawnedProcess( + child: ChildProcessByStdio, + process: AcpProcessStarted, + ): Promise { + let releaseExitNotification = () => {}; + const exitNotificationBarrier = new Promise((resolve) => { + releaseExitNotification = resolve; + }); + this.attachAgentLifecycleObservers(child, process, exitNotificationBarrier); + try { + await this.options.processLifecycle?.onSpawned?.(process); + } catch (error) { + await this.terminateAgentProcess(child); + throw error; + } finally { + releaseExitNotification(); } - return requireAgentStdio(spawnedChild); } private createConnection( @@ -1783,6 +1859,7 @@ export class AcpClient { startupStderr: string[], ): StartupFailureWatcher { let settled = false; + let failure: AgentStartupError | undefined; let rejectPromise: (error: unknown) => void; const cleanup = () => { @@ -1791,13 +1868,14 @@ export class AcpClient { child.off("close", onClose); }; - const finish = (error?: unknown) => { + const finish = (error?: AgentStartupError) => { if (settled) { return; } settled = true; cleanup(); if (error) { + failure = error; rejectPromise(error); } }; @@ -1832,11 +1910,16 @@ export class AcpClient { child.once("error", onError); child.once("exit", onExit); child.once("close", onClose); + if (child.exitCode !== null || child.signalCode !== null) { + onExit(child.exitCode, child.signalCode); + } }); + void promise.catch(() => {}); return { promise, dispose: () => finish(), + getError: () => failure, }; } @@ -2168,9 +2251,15 @@ export class AcpClient { private attachAgentLifecycleObservers( child: ChildProcessByStdio, + startedProcess: AcpProcessStarted, + exitNotificationBarrier: Promise, ): void { child.once("exit", (exitCode, signal) => { + const exitedAt = isoNow(); this.recordAgentExit("process_exit", exitCode, signal); + void exitNotificationBarrier.then(() => { + this.notifyProcessExit(startedProcess, exitCode, signal, exitedAt); + }); }); child.once("close", (exitCode, signal) => { @@ -2182,6 +2271,55 @@ export class AcpClient { }); } + private notifyProcessSpawnFailure(launch: AcpProcessLaunch, error: unknown): void { + const handler = this.options.processLifecycle?.onSpawnFailed; + if (!handler) { + return; + } + const event = Object.freeze({ + ...launch, + error, + failedAt: isoNow(), + }); + try { + void Promise.resolve(handler(event)).catch((observerError: unknown) => { + this.logProcessLifecycleError("onSpawnFailed", observerError); + }); + } catch (observerError) { + this.logProcessLifecycleError("onSpawnFailed", observerError); + } + } + + private notifyProcessExit( + startedProcess: AcpProcessStarted, + exitCode: number | null, + signal: NodeJS.Signals | null, + exitedAt: string, + ): void { + const handler = this.options.processLifecycle?.onExit; + if (!handler) { + return; + } + const event = Object.freeze({ + ...startedProcess, + exitCode, + signal, + exitedAt, + }); + try { + void Promise.resolve(handler(event)).catch((error: unknown) => { + this.logProcessLifecycleError("onExit", error); + }); + } catch (error) { + this.logProcessLifecycleError("onExit", error); + } + } + + private logProcessLifecycleError(hook: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + this.log(`process lifecycle ${hook} hook failed: ${message}`); + } + private recordAgentExit( reason: AgentDisconnectReason, exitCode: number | null, diff --git a/src/runtime.ts b/src/runtime.ts index 5b0cb078..ba5e336c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -53,6 +53,12 @@ export type { AcpFileSessionStoreOptions, AcpPermissionDecision, AcpPermissionRequest, + AcpProcessExit, + AcpProcessLaunch, + AcpProcessLaunchScope, + AcpProcessLifecycle, + AcpProcessSpawnFailure, + AcpProcessStarted, AcpRuntime, AcpRuntimeAvailableCommand, AcpRuntimeCapabilities, diff --git a/src/runtime/engine/manager.ts b/src/runtime/engine/manager.ts index bf2001e6..d58f0b8e 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -895,7 +895,15 @@ export class AcpRuntimeManager { sessionRecordId: record.acpxRecordId, loadRecord: async (sessionRecordId) => await this.requireRecord(sessionRecordId), saveRecord: async (connectedRecord) => await this.options.sessionStore.save(connectedRecord), - createClient: (options) => this.createClient(options), + createClient: (options) => + this.createClient({ + ...options, + processLifecycle: this.options.processLifecycle, + processLaunchScope: { + kind: "runtime-session", + sessionKey: record.name ?? record.acpxRecordId, + }, + }), mcpServers: [...(this.options.mcpServers ?? [])], permissionMode: this.options.permissionMode, nonInteractivePermissions: this.options.nonInteractivePermissions, @@ -1070,6 +1078,8 @@ export class AcpRuntimeManager { permissionPolicy: this.options.permissionPolicy, onPermissionRequest: this.options.onPermissionRequest, elicitationModes: this.options.elicitationModes, + processLifecycle: this.options.processLifecycle, + processLaunchScope: { kind: "runtime-session", sessionKey: input.sessionKey }, verbose: this.options.verbose, sessionOptions: input.sessionOptions, }); @@ -1493,6 +1503,11 @@ export class AcpRuntimeManager { permissionPolicy: this.options.permissionPolicy, onPermissionRequest: this.options.onPermissionRequest, elicitationModes: this.options.elicitationModes, + processLifecycle: this.options.processLifecycle, + processLaunchScope: { + kind: "runtime-session", + sessionKey: record.name ?? record.acpxRecordId, + }, verbose: this.options.verbose, sessionOptions: sessionOptionsFromRecord(record), }); @@ -2043,6 +2058,11 @@ export class AcpRuntimeManager { permissionPolicy: this.options.permissionPolicy, onPermissionRequest: this.options.onPermissionRequest, elicitationModes: this.options.elicitationModes, + processLifecycle: this.options.processLifecycle, + processLaunchScope: { + kind: "runtime-session", + sessionKey: record.name ?? record.acpxRecordId, + }, verbose: this.options.verbose, }), }; diff --git a/src/runtime/public/contract.ts b/src/runtime/public/contract.ts index 7a798020..9de71d3f 100644 --- a/src/runtime/public/contract.ts +++ b/src/runtime/public/contract.ts @@ -4,6 +4,7 @@ import type { AcpElicitationMode, AcpPermissionDecision, AcpPermissionRequest, + AcpProcessLifecycle, McpServer, NonInteractivePermissionPolicy, PermissionMode, @@ -22,6 +23,12 @@ export type { AcpElicitationResponse, AcpPermissionDecision, AcpPermissionRequest, + AcpProcessExit, + AcpProcessLaunch, + AcpProcessLaunchScope, + AcpProcessLifecycle, + AcpProcessSpawnFailure, + AcpProcessStarted, PermissionPolicy, } from "../../types.js"; @@ -354,6 +361,8 @@ export type AcpRuntimeOptions = { verbose?: boolean; /** ACP elicitation modes the embedding host can render for prompt turns. */ elicitationModes?: readonly AcpElicitationMode[]; + /** Optional lifecycle observer for ACP agent processes owned by this runtime. */ + processLifecycle?: AcpProcessLifecycle; onPermissionRequest?: ( req: AcpPermissionRequest, ctx: { signal: AbortSignal }, diff --git a/src/runtime/public/probe.ts b/src/runtime/public/probe.ts index db494991..e27b084c 100644 --- a/src/runtime/public/probe.ts +++ b/src/runtime/public/probe.ts @@ -77,7 +77,7 @@ export async function probeRuntime( ): Promise { const agentName = options.probeAgent?.trim() || DEFAULT_AGENT_NAME; const agentCommand = normalizeAgentCommandInput(options.agentRegistry.resolve(agentName)); - const client = createProbeClient(options, agentCommand, deps); + const client = createProbeClient(options, agentName, agentCommand, deps); try { await client.start(); @@ -111,6 +111,7 @@ export async function probeRuntime( function createProbeClient( options: AcpRuntimeOptions, + agentName: string, agentCommand: ReturnType, deps: ProbeRuntimeDeps, ): AcpClient { @@ -121,6 +122,8 @@ function createProbeClient( permissionMode: options.permissionMode, nonInteractivePermissions: options.nonInteractivePermissions, permissionPolicy: options.permissionPolicy, + processLifecycle: options.processLifecycle, + processLaunchScope: { kind: "runtime-probe" as const, agent: agentName }, verbose: options.verbose, }; return deps.clientFactory?.(clientOptions) ?? new AcpClient(clientOptions); diff --git a/src/types.ts b/src/types.ts index 17d7b6a9..17172478 100644 --- a/src/types.ts +++ b/src/types.ts @@ -231,6 +231,52 @@ export interface OutputFormatter { flush(): void; } +export type AcpProcessLaunchScope = + | Readonly<{ kind: "client" }> + | Readonly<{ kind: "runtime-session"; sessionKey: string }> + | Readonly<{ kind: "runtime-probe"; agent: string }>; + +export type AcpProcessLaunch = Readonly<{ + launchId: string; + scope: AcpProcessLaunchScope; + command: string; + args: readonly string[]; + cwd: string; +}>; + +export type AcpProcessStarted = AcpProcessLaunch & + Readonly<{ + pid: number; + startedAt: string; + }>; + +export type AcpProcessSpawnFailure = AcpProcessLaunch & + Readonly<{ + error: unknown; + failedAt: string; + }>; + +export type AcpProcessExit = AcpProcessStarted & + Readonly<{ + exitCode: number | null; + signal: NodeJS.Signals | null; + exitedAt: string; + }>; + +/** + * Optional process lifecycle seam for embedding hosts that persist their own + * launch ownership. Pre-spawn and spawned hooks are admission boundaries: + * rejecting either aborts startup, and a process rejected after spawn is + * terminated before the error is returned. Failure and exit hooks are + * best-effort observations and cannot replace the launch or exit outcome. + */ +export type AcpProcessLifecycle = { + onBeforeSpawn?: (launch: AcpProcessLaunch) => Promise | void; + onSpawned?: (process: AcpProcessStarted) => Promise | void; + onSpawnFailed?: (failure: AcpProcessSpawnFailure) => Promise | void; + onExit?: (exit: AcpProcessExit) => Promise | void; +}; + export type AcpClientOptions = { agentCommand: string; agentArgv?: string[]; @@ -244,6 +290,8 @@ export type AcpClientOptions = { fs?: boolean; terminal?: boolean; elicitationModes?: readonly AcpElicitationMode[]; + processLifecycle?: AcpProcessLifecycle; + processLaunchScope?: AcpProcessLaunchScope; suppressSdkConsoleErrors?: boolean; verbose?: boolean; sessionOptions?: { diff --git a/test/client.test.ts b/test/client.test.ts index b813e216..c8764a6a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -14,6 +14,7 @@ import { } from "../src/acp/client.js"; import { AgentDisconnectedError, + AgentSpawnError, AgentStartupError, AuthPolicyError, PermissionDeniedError, @@ -1417,6 +1418,269 @@ test("AcpClient prompt rejects when the agent disconnects mid-prompt", async () assert.equal(client.hasActivePrompt(), false); }); +test("AcpClient reports ordered process lifecycle events to embedding hosts", async () => { + const observed: string[] = []; + let launchId: string | undefined; + let pid: number | undefined; + let resolveExit: (() => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const client = makeClient({ + agentCommand: process.execPath, + agentArgv: [process.execPath, path.join(process.cwd(), "dist-test", "test", "mock-agent.js")], + processLaunchScope: { kind: "runtime-session", sessionKey: "lease-session" }, + processLifecycle: { + onBeforeSpawn: (launch) => { + observed.push("before"); + launchId = launch.launchId; + assert.equal(Object.isFrozen(launch), true); + assert.equal(Object.isFrozen(launch.args), true); + assert.deepEqual(launch.scope, { + kind: "runtime-session", + sessionKey: "lease-session", + }); + assert.equal(launch.cwd, process.cwd()); + assert(launch.command.length > 0); + }, + onSpawned: (started) => { + observed.push("spawned"); + assert.equal(started.launchId, launchId); + assert.equal(Object.isFrozen(started), true); + assert(Number.isInteger(started.pid)); + assert(started.startedAt.length > 0); + pid = started.pid; + }, + onSpawnFailed: () => { + assert.fail("spawn should succeed"); + }, + onExit: (exit) => { + observed.push("exit"); + assert.equal(exit.launchId, launchId); + assert.equal(exit.pid, pid); + assert(exit.exitedAt.length > 0); + resolveExit?.(); + }, + }, + }); + + await client.start(); + await client.close(); + await exited; + + assert.deepEqual(observed, ["before", "spawned", "exit"]); +}); + +test("AcpClient aborts before spawn when lifecycle admission fails", async () => { + const admissionError = new Error("lease persistence failed"); + let spawned = false; + const client = makeClient({ + agentCommand: process.execPath, + agentArgv: [process.execPath, path.join(process.cwd(), "dist-test", "test", "mock-agent.js")], + processLifecycle: { + onBeforeSpawn: async () => { + throw admissionError; + }, + onSpawned: () => { + spawned = true; + }, + }, + }); + + await assert.rejects( + () => client.start(), + (error: unknown) => { + assert.equal(error, admissionError); + return true; + }, + ); + assert.equal(spawned, false); +}); + +test("AcpClient correlates spawn failures with the prepared launch", async () => { + let launchId: string | undefined; + let failureLaunchId: string | undefined; + let observedFailure: unknown; + let exited = false; + const client = makeClient({ + agentCommand: "acpx-test-missing-agent", + agentArgv: ["acpx-test-missing-agent"], + processLifecycle: { + onBeforeSpawn: (launch) => { + launchId = launch.launchId; + }, + onSpawnFailed: (failure) => { + failureLaunchId = failure.launchId; + observedFailure = failure.error; + assert(failure.failedAt.length > 0); + }, + onExit: () => { + exited = true; + }, + }, + }); + + await assert.rejects( + () => client.start(), + (error: unknown) => { + assert(error instanceof AgentSpawnError); + assert.equal(error, observedFailure); + return true; + }, + ); + assert.equal(failureLaunchId, launchId); + assert.equal(exited, false); +}); + +test("AcpClient does not await non-settling spawn failure observers", async () => { + let observerCalled = false; + const client = makeClient({ + agentCommand: "acpx-test-missing-agent", + agentArgv: ["acpx-test-missing-agent"], + processLifecycle: { + onSpawnFailed: () => { + observerCalled = true; + return new Promise(() => {}); + }, + }, + }); + + const result = await Promise.race([ + client.start().then( + () => ({ type: "resolved" as const }), + (error: unknown) => ({ type: "rejected" as const, error }), + ), + new Promise<{ type: "timeout" }>((resolve) => { + setTimeout(() => resolve({ type: "timeout" }), 100); + }), + ]); + + assert.equal(observerCalled, true); + assert.equal(result.type, "rejected"); + assert(result.error instanceof AgentSpawnError); +}); + +test("AcpClient terminates a spawned process when spawned admission fails", async () => { + const admissionError = new Error("spawned lease persistence failed"); + let spawnedPid: number | undefined; + let exitedPid: number | undefined; + let resolveExit: (() => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const client = makeClient({ + agentCommand: process.execPath, + agentArgv: [process.execPath, path.join(process.cwd(), "dist-test", "test", "mock-agent.js")], + processLifecycle: { + onSpawned: (started) => { + spawnedPid = started.pid; + throw admissionError; + }, + onExit: (exit) => { + exitedPid = exit.pid; + resolveExit?.(); + }, + }, + }); + + await assert.rejects( + () => client.start(), + (error: unknown) => { + assert.equal(error, admissionError); + return true; + }, + ); + await exited; + + assert.equal(exitedPid, spawnedPid); +}); + +test("AcpClient reports an early exit after spawned admission settles", async () => { + const admissionError = new Error("spawned lease persistence failed"); + const observed: string[] = []; + let resolveExit: (() => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const client = makeClient({ + agentCommand: process.execPath, + agentArgv: [process.execPath, path.join(process.cwd(), "dist-test", "test", "mock-agent.js")], + processLifecycle: { + onSpawned: async (started) => { + observed.push("spawned:start"); + process.kill(started.pid); + await new Promise((resolve) => setTimeout(resolve, 100)); + observed.push("spawned:end"); + throw admissionError; + }, + onExit: () => { + observed.push("exit"); + resolveExit?.(); + }, + }, + }); + + await assert.rejects( + () => client.start(), + (error: unknown) => { + assert.equal(error, admissionError); + return true; + }, + ); + await exited; + + assert.deepEqual(observed, ["spawned:start", "spawned:end", "exit"]); +}); + +test("AcpClient rejects when the agent exits during successful spawned admission", async () => { + const stderrLine = "exited during spawned admission"; + const observed: string[] = []; + let resolveExit: (() => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const client = makeClient({ + agentCommand: process.execPath, + agentArgv: [ + process.execPath, + "--eval", + `setTimeout(() => { + process.stderr.write(${JSON.stringify(`${stderrLine}\n`)}); + process.exit(17); + }, 20);`, + ], + processLifecycle: { + onSpawned: async () => { + observed.push("spawned:start"); + await new Promise((resolve) => setTimeout(resolve, 100)); + observed.push("spawned:end"); + }, + onExit: () => { + observed.push("exit"); + resolveExit?.(); + }, + }, + }); + + const result = await Promise.race([ + client.start().then( + () => ({ type: "resolved" as const }), + (error: unknown) => ({ type: "rejected" as const, error }), + ), + new Promise<{ type: "timeout" }>((resolve) => { + setTimeout(() => resolve({ type: "timeout" }), 2_000); + }), + ]); + + assert.equal(result.type, "rejected"); + assert(result.error instanceof AgentStartupError); + assert.equal(result.error.exitCode, 17); + assert.equal(result.error.signal, null); + assert.match(result.error.message, /exited during spawned admission/); + await exited; + assert.deepEqual(observed, ["spawned:start", "spawned:end", "exit"]); +}); + test("AcpClient start fails fast when the agent exits during initialize", async () => { const stderrLine = "startup boom"; const client = makeClient({ diff --git a/test/runtime-manager.test.ts b/test/runtime-manager.test.ts index 664f0f84..54ea270c 100644 --- a/test/runtime-manager.test.ts +++ b/test/runtime-manager.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import path from "node:path"; import test from "node:test"; import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"; import type { SessionModelState } from "../src/acp/model-support.js"; @@ -224,6 +225,7 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async escalate: ["execute"], defaultAction: "deny" as const, }; + const processLifecycle = {}; const lifecycle = { pid: 456, startedAt: "2026-01-01T00:00:00.000Z", @@ -237,7 +239,7 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async start: async () => {}, close: async () => {}, createSession: async (cwd) => { - assert.equal(cwd, "/workspace"); + assert.equal(cwd, path.resolve("/workspace")); return { sessionId: "new-session", agentSessionId: "agent-session", @@ -257,7 +259,7 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async }, resumeSession: async (sessionId, cwd) => { assert.equal(sessionId, "resume-session"); - assert.equal(cwd, "/workspace"); + assert.equal(cwd, path.resolve("/workspace")); return { agentSessionId: "resumed-agent", configOptions: [ @@ -286,7 +288,12 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async }); const constructedOptions: Array> = []; const manager = new AcpRuntimeManager( - createRuntimeOptions({ cwd: "/workspace", sessionStore: store, permissionPolicy }), + createRuntimeOptions({ + cwd: "/workspace", + sessionStore: store, + permissionPolicy, + processLifecycle, + }), { clientFactory: (options) => { constructedOptions.push(options); @@ -327,6 +334,17 @@ test("AcpRuntimeManager creates and resumes sessions through the client", async constructedOptions.map((options) => options.permissionPolicy), [permissionPolicy, permissionPolicy], ); + assert.deepEqual( + constructedOptions.map((options) => options.processLifecycle), + [processLifecycle, processLifecycle], + ); + assert.deepEqual( + constructedOptions.map((options) => options.processLaunchScope), + [ + { kind: "runtime-session", sessionKey: "created-session" }, + { kind: "runtime-session", sessionKey: "resumed-session" }, + ], + ); }); test("AcpRuntimeManager reuses pending oneshot initialization and closes it after the turn", async () => { diff --git a/test/runtime-probe.test.ts b/test/runtime-probe.test.ts index 0b7c24db..55bf0775 100644 --- a/test/runtime-probe.test.ts +++ b/test/runtime-probe.test.ts @@ -16,11 +16,13 @@ test("probeRuntime uses the default agent override and reports protocol details" escalate: ["execute"], defaultAction: "deny" as const, }; + const processLifecycle = {}; const report = await probeRuntime( createRuntimeOptions({ cwd: "/workspace", sessionStore: store, permissionPolicy, + processLifecycle, agentRegistry: createAgentRegistry({ overrides: { claude: "broken-claude-acp", @@ -43,6 +45,11 @@ test("probeRuntime uses the default agent override and reports protocol details" assert.equal(report.ok, true); assert.equal(constructed[0]?.agentCommand, "codex-override --acp"); assert.deepEqual(constructed[0]?.permissionPolicy, permissionPolicy); + assert.equal(constructed[0]?.processLifecycle, processLifecycle); + assert.deepEqual(constructed[0]?.processLaunchScope, { + kind: "runtime-probe", + agent: "codex", + }); assert.deepEqual(report.details, [ "agent=codex", "command=codex-override --acp", diff --git a/test/runtime-test-helpers.ts b/test/runtime-test-helpers.ts index da4dcc76..72c6e482 100644 --- a/test/runtime-test-helpers.ts +++ b/test/runtime-test-helpers.ts @@ -147,6 +147,7 @@ export function createRuntimeOptions(params: { sessionStore: AcpSessionStore; agentRegistry?: AcpAgentRegistry; permissionPolicy?: AcpRuntimeOptions["permissionPolicy"]; + processLifecycle?: AcpRuntimeOptions["processLifecycle"]; timeoutMs?: number; }): AcpRuntimeOptions { return { @@ -163,5 +164,6 @@ export function createRuntimeOptions(params: { }, permissionMode: "approve-reads", permissionPolicy: params.permissionPolicy, + processLifecycle: params.processLifecycle, }; }