diff --git a/readme-dev.md b/readme-dev.md index 836df35d..55884f2f 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -212,7 +212,7 @@ schema alone: channel. The same `_meta` v1 attaches to `PromptResponse` is copied onto the v2 idle update. - Custom session updates are renamed on v2: `subagent_spawned`/`subagent_state_update` become a single `_subagent_update` (gated on the AIR `nativeSubagentSessions` capability; this contract is - retained until the adapter migrates to the standard subagent model), and + retained independently of standard subagent sessions), and `async_task_spawned`/`async_task_state_update` become `_async_task_spawned`/`_async_task_state_update` (gated on the AIR `asyncTasks` capability). Payload shapes are unchanged. - Foreground state is independent of prompt acceptance. `running` can precede the user-message @@ -242,6 +242,29 @@ schema alone: - Other AIR `_meta` keys (goal, message phase, mode kind, permission presentation) go only to AIR clients, as on v1. Every v2 permission request still gets its prompt `title` and `description`. +#### Live standard subagent sessions + +Non-AIR v1 clients opt in with `clientCapabilities.subagents: {}`. Non-AIR v2 clients use +standard `subagent_update` associations automatically. AIR keeps its existing opt-in, updates, +and generation-based identities; clients do not receive both contracts. + +A standard child keeps one opaque conversation ID across assignments and parent handler changes. +Its ancestors are announced before its output, tool updates, or interactions. Permissions and +elicitation use the child's ID and survive parent replay; actual work endings withdraw obsolete +requests locally, even if the client returns a late answer. Child tools, terminals, plans, titles, +and observed model options stay separate from the parent. + +Capabilities are empty: direct child prompting, loading, configuration, cancellation, and other +client mutations are not enabled. Parent cancellation interrupts observed live descendants. +An observed work ending reports `idle`; loss of observability reports `unknown`, not a fabricated +failure or a permanently closed conversation. + +Replay currently restores recoverable associations, not recursive child transcripts or historical +work outcomes. A parent advisory makes that gap visible. Rejoining a loaded child requires current +provider parentage; copied fork history alone cannot acquire the original runtime's subscriptions. +After replay, current child work/model snapshots follow the actual response write, including whole +JSON-RPC batches. Parent setup operations serialize without waiting on each other's batch delivery. + #### Models of a `providers/set` gateway `providers/set` lists the models of the gateway (`GET {baseUrl}/models`, with the headers Codex sends to it) diff --git a/src/ACPSessionConnection.ts b/src/ACPSessionConnection.ts index 73c5c09c..6341e7fa 100644 --- a/src/ACPSessionConnection.ts +++ b/src/ACPSessionConnection.ts @@ -248,6 +248,17 @@ export class ACPSessionConnection { await this.v2Connection.updateState(this.sessionId, state); } + async updateSubagentState(childSessionId: string, parentSessionId: string, state: acpV2.StateUpdate): Promise { + if (this.v2Connection) { + await this.v2Connection.client.notify(acpV2.methods.client.session.update, { + sessionId: childSessionId, update: {sessionUpdate: "state_update", ...state}, + }); + } + await this.update({sessionUpdate: "subagent_update", sessionId: childSessionId, state}, parentSessionId); + } + + forSession(sessionId: string): ACPSessionConnection { return new ACPSessionConnection(this.connection, sessionId); } + /** `startReplayMessage`, which only exists on v2 and so needs no v1 shape. */ async startReplayMessage(kind: ReplayMessageKind, messageId: string): Promise { if (!this.v2Connection) { diff --git a/src/AcpAgentRouter.ts b/src/AcpAgentRouter.ts index b420c37a..c561be4e 100644 --- a/src/AcpAgentRouter.ts +++ b/src/AcpAgentRouter.ts @@ -4,6 +4,7 @@ import {z} from "zod"; import type {CodexAcpServer} from "./CodexAcpServer"; import {type AcpClientConnection, AcpV2Connection} from "./ACPSessionConnection"; import packageJson from "../package.json"; +import {AcpResponseDelivery} from "./AcpResponseDelivery"; import { GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, SESSION_STEERING_METHOD, @@ -65,17 +66,73 @@ const hooksTrustParamsParser = z.object({ */ export function createAcpAgentRouter( createAgent: (connection: AcpClientConnection | AcpV2Connection) => CodexAcpServer, +): Pick { + return { + connect(stream, options) { + const responses = new AcpResponseDelivery(); + const connection = createRouter(createAgent, responses).connect(responses.observe(stream), options); + void connection.closed.then(() => responses.dispose()); + return connection; + }, + }; +} + +function createRouter( + createAgent: (connection: AcpClientConnection | AcpV2Connection) => CodexAcpServer, + responses: AcpResponseDelivery, ): acpV2.AgentProtocolRouter { let codexAcpServer: CodexAcpServer | null = null; - const getAgent = (): CodexAcpServer => { + const getAgent = (params?: {sessionId?: unknown}): CodexAcpServer => { if (!codexAcpServer) { throw acp.RequestError.internalError("ACP agent is not connected"); } + if (typeof params?.sessionId === "string") codexAcpServer.assertSessionMutationAllowed(params.sessionId); return codexAcpServer; }; + type SetupState = {tail: Promise; publication: Promise; epoch: number; pending: number}; + const setups = new Map(); + const setup = (sessionId: string, requestId: acpV2.JsonRpcId, operation: () => Promise, signal: AbortSignal): Promise => { + const agent = getAgent({sessionId}); + if (!agent.usesStandardSubagents()) return operation(); + const assertCurrent = agent.captureSessionSetup(sessionId); + const state = setups.get(sessionId) ?? {tail: Promise.resolve(), publication: Promise.resolve(), epoch: 0, pending: 0}; + setups.set(sessionId, state); + state.pending++; + const finish = () => { + if (--state.pending === 0 && setups.get(sessionId) === state) setups.delete(sessionId); + }; + let resolve!: (response: T) => void; + let reject!: (error: unknown) => void; + const response = new Promise((done, failed) => { resolve = done; reject = failed; }); + state.tail = state.tail.catch(() => {}).then(async () => { + let registered = false; + try { + for (;;) { + const publication = state.publication; + await publication; + if (publication === state.publication) break; + } + if (signal.aborted) throw new acp.RequestError(-32800, "Session setup was cancelled"); + assertCurrent(); + const epoch = ++state.epoch; + responses.afterWrite(requestId, () => { + if (epoch === state.epoch) state.publication = agent.afterSessionSetupResponse(sessionId); + void state.publication.then(finish, finish); + }); + registered = true; + resolve(await operation()); + } catch (error) { + reject(error); + if (!registered) finish(); + } + }); + // Waiting for delivery here would deadlock two setup requests in the same batch. + return response; + }; const attachAgent = (agent: CodexAcpServer, signal: AbortSignal) => { codexAcpServer = agent; signal.addEventListener("abort", () => { + setups.clear(); agent.sessionIndex.dispose(); if (codexAcpServer === agent) { codexAcpServer = null; @@ -87,32 +144,32 @@ export function createAcpAgentRouter( .onConnect((connection) => attachAgent(createAgent(connection.client), connection.signal)) .onRequest(acp.methods.agent.initialize, (ctx) => getAgent().initialize(ctx.params)) .onRequest(acp.methods.agent.session.new, (ctx) => getAgent().newSession(ctx.params)) - .onRequest(acp.methods.agent.session.load, (ctx) => getAgent().loadSession(ctx.params)) - .onRequest(acp.methods.agent.session.fork, (ctx) => getAgent().forkSession(ctx.params)) + .onRequest(acp.methods.agent.session.load, (ctx) => setup(ctx.params.sessionId, ctx.requestId, () => getAgent(ctx.params).loadSession(ctx.params), ctx.signal)) + .onRequest(acp.methods.agent.session.fork, (ctx) => getAgent(ctx.params).forkSession(ctx.params)) .onRequest(acp.methods.agent.session.list, (ctx) => getAgent().listSessions(ctx.params)) - .onRequest(acp.methods.agent.session.delete, (ctx) => getAgent().deleteSession(ctx.params)) - .onRequest(acp.methods.agent.session.resume, (ctx) => getAgent().resumeSession(ctx.params)) - .onRequest(acp.methods.agent.session.close, (ctx) => getAgent().closeSession(ctx.params)) - .onRequest(acp.methods.agent.session.setMode, (ctx) => getAgent().setSessionMode(ctx.params)) - .onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)) + .onRequest(acp.methods.agent.session.delete, (ctx) => getAgent(ctx.params).deleteSession(ctx.params)) + .onRequest(acp.methods.agent.session.resume, (ctx) => setup(ctx.params.sessionId, ctx.requestId, () => getAgent(ctx.params).resumeSession(ctx.params), ctx.signal)) + .onRequest(acp.methods.agent.session.close, (ctx) => getAgent(ctx.params).closeSession(ctx.params)) + .onRequest(acp.methods.agent.session.setMode, (ctx) => getAgent(ctx.params).setSessionMode(ctx.params)) + .onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent(ctx.params).setSessionConfigOption(ctx.params)) .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params, ctx.requestId)) .onRequest(acp.methods.agent.logout, (ctx) => getAgent().logout(ctx.params)) .onRequest(acp.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params)) .onRequest(acp.methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params)) .onRequest(acp.methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params)) - .onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal)) + .onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent(ctx.params).prompt(ctx.params, ctx.signal)) .onNotification(acp.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params)) .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) - .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) - .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) - .onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent(ctx.params).extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent(ctx.params).extMethod(SESSION_STEERING_METHOD, ctx.params)) + .onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent(ctx.params).extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) .onRequest(CODEX_HOOKS_LIST_METHOD, hooksListParamsParser, (ctx) => getAgent().listHooks(ctx.params.cwd)) .onRequest(CODEX_HOOKS_TRUST_METHOD, hooksTrustParamsParser, (ctx) => getAgent().trustHooks(ctx.params.cwd, ctx.params.hooks)) - .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) - .onRequest(SESSION_RENAME_METHOD, sessionRenameParamsParser, (ctx) => getAgent().sessionIndex.rename(ctx.params)) - .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, true)) - .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, false)) + .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent(ctx.params).extMethod(GOAL_CONTROL_METHOD, ctx.params)) + .onRequest(SESSION_RENAME_METHOD, sessionRenameParamsParser, (ctx) => getAgent(ctx.params).sessionIndex.rename(ctx.params)) + .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent(ctx.params).sessionIndex.setArchived(ctx.params, true)) + .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent(ctx.params).sessionIndex.setArchived(ctx.params, false)) .onRequest(SESSION_LIST_SUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.subscribeList(ctx.params)) .onRequest(SESSION_LIST_UNSUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.unsubscribeList(ctx.params)); @@ -122,26 +179,26 @@ export function createAcpAgentRouter( // No `session/load`: v2 folds it into `session/resume` with `replayFrom`. .onRequest(acpV2.methods.agent.session.new, (ctx) => getAgent().newSessionV2(ctx.params)) .onRequest(acpV2.methods.agent.session.list, (ctx) => getAgent().listSessions(ctx.params)) - .onRequest(acpV2.methods.agent.session.delete, (ctx) => getAgent().deleteSession(ctx.params)) - .onRequest(acpV2.methods.agent.session.resume, (ctx) => getAgent().resumeSessionV2(ctx.params)) - .onRequest(acpV2.methods.agent.session.fork, (ctx) => getAgent().forkSessionV2(ctx.params)) - .onRequest(acpV2.methods.agent.session.close, (ctx) => getAgent().closeSession(ctx.params)) + .onRequest(acpV2.methods.agent.session.delete, (ctx) => getAgent(ctx.params).deleteSession(ctx.params)) + .onRequest(acpV2.methods.agent.session.resume, (ctx) => setup(ctx.params.sessionId, ctx.requestId, () => getAgent(ctx.params).resumeSessionV2(ctx.params), ctx.signal)) + .onRequest(acpV2.methods.agent.session.fork, (ctx) => getAgent(ctx.params).forkSessionV2(ctx.params)) + .onRequest(acpV2.methods.agent.session.close, (ctx) => getAgent(ctx.params).closeSession(ctx.params)) // No `session/set_mode`: v2 removed the modes API; modes are config options. - .onRequest(acpV2.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOptionV2(ctx.params)) + .onRequest(acpV2.methods.agent.session.setConfigOption, (ctx) => getAgent(ctx.params).setSessionConfigOptionV2(ctx.params)) // The v1-only `authentication/status|logout` extensions are not registered on v2. .onRequest(acpV2.methods.agent.auth.login, (ctx) => getAgent().authenticateV2(ctx.params, ctx.requestId)) .onRequest(acpV2.methods.agent.auth.logout, (ctx) => getAgent().logoutV2(ctx.params)) - .onRequest(acpV2.methods.agent.session.prompt, (ctx) => getAgent().promptV2(ctx.params, ctx.signal)) + .onRequest(acpV2.methods.agent.session.prompt, (ctx) => getAgent(ctx.params).promptV2(ctx.params, ctx.signal)) .onNotification(acpV2.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params)) .onRequest(acpV2.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params)) .onRequest(acpV2.methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params)) .onRequest(acpV2.methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params)) - .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) - .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) - .onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) - .onRequest(SESSION_RENAME_METHOD, sessionRenameParamsParser, (ctx) => getAgent().sessionIndex.rename(ctx.params)) - .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, true)) - .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, false)) + .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent(ctx.params).extMethod(SESSION_STEERING_METHOD, ctx.params)) + .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent(ctx.params).extMethod(GOAL_CONTROL_METHOD, ctx.params)) + .onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent(ctx.params).extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(SESSION_RENAME_METHOD, sessionRenameParamsParser, (ctx) => getAgent(ctx.params).sessionIndex.rename(ctx.params)) + .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent(ctx.params).sessionIndex.setArchived(ctx.params, true)) + .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent(ctx.params).sessionIndex.setArchived(ctx.params, false)) .onRequest(SESSION_LIST_SUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.subscribeList(ctx.params)) .onRequest(SESSION_LIST_UNSUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.unsubscribeList(ctx.params)); diff --git a/src/AcpResponseDelivery.ts b/src/AcpResponseDelivery.ts new file mode 100644 index 00000000..99cf0f1d --- /dev/null +++ b/src/AcpResponseDelivery.ts @@ -0,0 +1,42 @@ +import type {AnyWireMessage, JsonRpcId, WireStream} from "@agentclientprotocol/sdk/experimental/v2/unstable"; + +/** Observe completed writes, including batches, without changing SDK framing. */ +export class AcpResponseDelivery { + private readonly callbacks = new Map void>(); + private disposed = false; + + afterWrite(requestId: JsonRpcId, callback: () => void): void { + if (!this.disposed) this.callbacks.set(requestId, callback); + } + + observe(stream: WireStream): WireStream { + return { + readable: stream.readable, + writable: new WritableStream({ + write: async message => { + const writer = stream.writable.getWriter(); + try { await writer.write(message); } + finally { writer.releaseLock(); } + for (const entry of Array.isArray(message) ? message : [message]) { + if ("method" in entry || entry.id === null) continue; + const callback = this.callbacks.get(entry.id); + this.callbacks.delete(entry.id); + callback?.(); + } + }, + close: async () => { + const writer = stream.writable.getWriter(); + try { await writer.close(); } + finally { this.dispose(); writer.releaseLock(); } + }, + abort: async reason => { + const writer = stream.writable.getWriter(); + try { await writer.abort(reason); } + finally { this.dispose(); writer.releaseLock(); } + }, + }), + }; + } + + dispose(): void { this.disposed = true; this.callbacks.clear(); } +} diff --git a/src/AcpSessionExtensions.ts b/src/AcpSessionExtensions.ts index 86e16daf..d27c3f47 100644 --- a/src/AcpSessionExtensions.ts +++ b/src/AcpSessionExtensions.ts @@ -1,6 +1,6 @@ import type {SessionNotification} from "@agentclientprotocol/sdk/unstable"; import type {AsyncTaskUpdate} from "./async-tasks/AcpAsyncTasks"; -import type {SubagentSpawnedUpdate, SubagentStateUpdate} from "./subagents/AcpSubagents"; +import type {StandardSubagentUpdate, SubagentSpawnedUpdate, SubagentStateUpdate} from "./subagents/AcpSubagents"; /** Updates the adapter emits, including its existing AIR subagent contract. */ export type AcpSessionUpdate = @@ -9,6 +9,7 @@ export type AcpSessionUpdate = }> | SubagentSpawnedUpdate | SubagentStateUpdate + | StandardSubagentUpdate | AsyncTaskUpdate; export type AcpSessionNotification = Omit & { diff --git a/src/AcpV2ForegroundState.ts b/src/AcpV2ForegroundState.ts index cab617e8..24bb06d5 100644 --- a/src/AcpV2ForegroundState.ts +++ b/src/AcpV2ForegroundState.ts @@ -1,7 +1,7 @@ import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import {logger} from "./Logger"; -type Activity = "idle" | "running" | "requires_action"; +type Activity = "idle" | "running" | "requires_action" | "unknown"; /** Session state and its wire order are independent of prompt acceptance and individual requests. */ export class AcpV2ForegroundState { @@ -12,17 +12,17 @@ export class AcpV2ForegroundState { constructor( private readonly notify: (state: acpV2.StateUpdate) => Promise, - running = false, + running: boolean | "unknown" = false, ) { - this.activity = running ? "running" : "idle"; + this.activity = running === "unknown" ? "unknown" : running ? "running" : "idle"; } async update(state: acpV2.StateUpdate): Promise { - if (state.state === "idle") { + if (state.state === "idle" || state.state === "unknown") { this.items.clear(); this.actions.clear(); - if (this.activity === "idle") return this.sending; - this.activity = "idle"; + if (this.activity === state.state) return this.sending; + this.activity = state.state; } else if (state.state === "running" || state.state === "requires_action") { if (this.activity === state.state) return this.sending; this.activity = state.state; @@ -44,6 +44,8 @@ export class AcpV2ForegroundState { return this.publish({state: "running"}); case "requires_action": return this.publish({state: "requires_action"}); + case "unknown": + return this.publish({state: "unknown"}); } } @@ -79,6 +81,12 @@ export class AcpV2ForegroundState { this.actions.clear(); } + invalidate(): void { + this.activity = "unknown"; + this.items.clear(); + this.actions.clear(); + } + private reportActionState(): Promise { const blockedItems = new Set(this.actions.values()); const runnable = [...this.items].some(itemId => !blockedItems.has(itemId)); @@ -97,7 +105,11 @@ export class AcpV2ForegroundState { } private publish(state: acpV2.StateUpdate): Promise { - const sent = this.sending.then(() => this.notify(state)); + return this.serializePublication(() => this.notify(state)); + } + + serializePublication(publish: () => Promise): Promise { + const sent = this.sending.then(publish); this.sending = sent.catch(() => {}); return sent; } diff --git a/src/AcpV2SessionUpdate.ts b/src/AcpV2SessionUpdate.ts index c4fb072e..6e59cf2f 100644 --- a/src/AcpV2SessionUpdate.ts +++ b/src/AcpV2SessionUpdate.ts @@ -120,8 +120,9 @@ export function toV2SessionUpdate(update: AcpSessionUpdate): acpV2.SessionUpdate ...(content !== undefined ? {content: content && content.map(toV2ToolCallContent)} : {}), }; } - // Preserve AIR's existing subagent contract. The standard schema uses different fields - // and work states; that migration is separate. The AIR capability gate already ran upstream. + case "subagent_update": + return update; + // AIR's private contract is selected independently upstream. case "subagent_spawned": { const {subagentSessionId, name, task, _meta} = update; return { diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 71e8b199..af288540 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -827,6 +827,12 @@ export class CodexAcpClient { return null; } + restoreSubagentSubscription(rootSessionId: string, parentThreadId: string, thread: Thread): Promise { + return this.subagents.restoreChild(rootSessionId, parentThreadId, thread); + } + + isSubagentThread(threadId: string): boolean { return this.subagents.isChildThread(threadId); } + async newSession(request: WithAcpMcpServers): Promise { const instructions = await this.resolveSessionInstructions(request, undefined, true); const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); @@ -1236,6 +1242,8 @@ export class CodexAcpClient { supportsSubagents: boolean, observeInteraction: (result: ServerNotification) => void | Promise, waitForChildSession: (childThreadId: string) => Promise, + withUserAction?: import("./subagents/CodexSubagentSubscriptions").SubagentInteraction, + observeWorkEnd?: (event: ServerNotification) => void, ) { const dispatch = (event: ServerNotification) => { this.enqueueSessionNotification(sessionId, () => eventHandler(event)); @@ -1254,6 +1262,23 @@ export class CodexAcpClient { elicitationHandler, waitForRootNotifications: () => this.waitForSessionNotifications(sessionId), waitForChildSession, + ...(withUserAction ? {withUserAction} : {}), + ...(observeWorkEnd ? {observeWorkEnd} : {}), + }); + } + + redispatchSessionNotifications(sessionId: string, notifications: ServerNotification[], after?: () => Promise): void { + this.subagents.redispatch(sessionId, notifications); + if (after) this.enqueueSessionNotification(sessionId, after); + } + + /** Completion includes async publication, not just invoking its callback. */ + enqueueSessionTransition(sessionId: string, operation: () => void | Promise): Promise { + return new Promise((resolve, reject) => { + this.enqueueSessionNotification(sessionId, async () => { + try { await operation(); resolve(); } + catch (error) { reject(error); } + }); }); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 3f7f160b..5ef4a025 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -167,7 +167,7 @@ import { toThreadGoalSnapshot, } from "./ThreadGoalSnapshot"; import { - clientSupportsSubagents, + subagentProtocol, type SubagentAwareSessionCapabilities, } from "./subagents/AcpSubagents"; import { @@ -175,7 +175,11 @@ import { reportSubagentModel, type SubagentModels, } from "./subagents/CodexSubagentEventRouter"; -import {nameFromAgentPath} from "./subagents/CodexAgentPath"; +import {isRootAgentPath, nameFromAgentPath} from "./subagents/CodexAgentPath"; +import {StandardSubagentEventRouter} from "./subagents/StandardSubagentEventRouter"; +import {SubagentAssociations} from "./subagents/SubagentAssociations"; +import {SubagentInteractionHandlers} from "./subagents/SubagentInteractionHandlers"; +import type {SubagentEventRouter} from "./subagents/SubagentEventRouter"; import {listCodexHooks, trustCodexHooks, type CodexHookIdentity} from "./CodexHookTrust"; import type {HooksListEntry} from "./app-server/v2/HooksListEntry"; import { @@ -277,7 +281,8 @@ export interface SessionState extends SessionIndexTitleState { sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; sessionFailure?: SessionFailure; titleGen?: TitleGenerator; - subagents: CodexSubagentEventRouter; + subagents: SubagentEventRouter; + subagentTransition?: Promise; asyncTasks: CodexBackgroundTerminalTasks; compactions: CodexSessionCompactions; toolCallReports: ToolCallReports; @@ -503,6 +508,8 @@ export class CodexAcpServer { private authStatusVersion = 0; private readonly sessions: Map; + private readonly subagentAssociations = new SubagentAssociations(); + private readonly subagentInteractionHandlers = new WeakMap(); private readonly mcpSessionStartup: McpSessionStartup; private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; @@ -629,6 +636,118 @@ export class CodexAcpServer { ); } + usesStandardSubagents(): boolean { + return subagentProtocol(this.clientCapabilities, this.protocolVersion) === "standard"; + } + + private supportsNativeSubagents(): boolean { + return subagentProtocol(this.clientCapabilities, this.protocolVersion) !== "hidden"; + } + + private createSubagentRouter(sessionId: string, replay = false): SubagentEventRouter { + const existing = this.sessions.get(sessionId)?.subagents; + if (this.usesStandardSubagents() && existing instanceof StandardSubagentEventRouter && !existing.isRetired()) return existing; + const session = new ACPSessionConnection(this.connection, sessionId); + const release = (id: string) => this.reportingConnection.reports.releaseOpen(id); + const models = () => { + const state = this.sessions.get(sessionId); + return state ? this.subagentModels(state) : null; + }; + const router = this.usesStandardSubagents() + ? new StandardSubagentEventRouter(sessionId, session, this.subagentAssociations, release, this.capabilities, replay, + this.sessions.get(sessionId)?.subagentTransition, + id => this.codexAcpClient.appServerClient.runningTurnId(id), models) + : new CodexSubagentEventRouter(sessionId, this.supportsNativeSubagents(), session, release, models); + if (router instanceof StandardSubagentEventRouter) this.getSubagentInteractionHandlers(router, sessionId); + return router; + } + + private getSubagentInteractionHandlers(router: StandardSubagentEventRouter, rootSessionId: string): SubagentInteractionHandlers { + const existing = this.subagentInteractionHandlers.get(router); + if (existing) return existing; + const handlers = new SubagentInteractionHandlers(this.connection, rootSessionId, this.clientCapabilities, + new AcpToolCallRenderer(this.capabilities)); + router.setInteractionObserver((event, id) => handlers.observe(event, id)); + this.subagentInteractionHandlers.set(router, handlers); + return handlers; + } + + private childInteractions(sessionId: string): {approvals: ApprovalHandler; elicitations: ElicitationHandler} | undefined { + const association = this.subagentAssociations.get(sessionId); + if (!association) return undefined; + const router = this.sessions.get(association.rootSessionId)?.subagents; + return router instanceof StandardSubagentEventRouter && !router.isRetired() + ? this.getSubagentInteractionHandlers(router, association.rootSessionId) + : {approvals: DENY_ALL_APPROVALS, elicitations: DENY_ALL_ELICITATIONS}; + } + + private routeChildApprovals(fallback: ApprovalHandler): ApprovalHandler { + return { + handleCommandExecution: params => (this.childInteractions(params.threadId)?.approvals ?? fallback).handleCommandExecution(params), + handleFileChange: params => (this.childInteractions(params.threadId)?.approvals ?? fallback).handleFileChange(params), + handlePermissionsRequest: params => (this.childInteractions(params.threadId)?.approvals ?? fallback).handlePermissionsRequest(params), + }; + } + + private routeChildElicitations(fallback: ElicitationHandler): ElicitationHandler { + return { + handleElicitation: params => (this.childInteractions(params.threadId)?.elicitations ?? fallback).handleElicitation(params), + handleUserInput: params => (this.childInteractions(params.threadId)?.elicitations ?? fallback).handleUserInput(params), + }; + } + + assertSessionMutationAllowed(sessionId: string): void { + if (this.isRestrictedChild(sessionId)) { + throw RequestError.invalidRequest(undefined, "Subagent sessions do not permit client-initiated mutations"); + } + } + + private isRestrictedChild(sessionId: string): boolean { + return this.subagentAssociations.isRestricted(sessionId) + || (this.usesStandardSubagents() && this.codexAcpClient.isSubagentThread(sessionId)); + } + + captureSessionSetup(sessionId: string): () => void { + const generation = this.getSessionGeneration(sessionId); + const check = () => { + this.assertInstructionsReady(sessionId); + if (this.sessionIsClosing(sessionId) || this.getSessionGeneration(sessionId) !== generation) { + throw RequestError.invalidRequest(undefined, "Session closed while setup was queued"); + } + }; + check(); + return check; + } + + afterSessionSetupResponse(sessionId: string): Promise { + const router = this.sessions.get(sessionId)?.subagents; + if (!(router instanceof StandardSubagentEventRouter) || this.sessionIsClosing(sessionId)) return Promise.resolve(); + this.codexAcpClient.redispatchSessionNotifications(sessionId, router.releaseReplay(), () => router.publishReplaySnapshots()); + return this.codexAcpClient.waitForSessionNotifications(sessionId).catch(error => { + logger.error("Failed to release child traffic after session setup", error); + }); + } + + private async interruptStandardChildren(session: SessionState): Promise { + if (!(session.subagents instanceof StandardSubagentEventRouter)) return; + const router = session.subagents; + const turns = router.activeTurns(); + router.cancelInteractions(); + await Promise.all(turns.map(async turn => { + try { + const turnId = this.codexAcpClient.appServerClient.runningTurnId(turn.threadId) ?? turn.turnId; + if (turnId === null) return; + router.closeWorkRequests(turn.threadId, turnId); + await this.codexAcpClient.turnInterrupt({threadId: turn.threadId, turnId}); + } catch (error) { logger.error(`Failed to interrupt child ${turn.threadId}`, error); } + })); + } + + private observeSubagentWorkEnd(sessionId: string, event: ServerNotification): void { + const router = this.sessions.get(sessionId)?.subagents; + if (router instanceof StandardSubagentEventRouter) router.observeWorkEnd(event); + } + async initialize( _params: acp.InitializeRequest, ): Promise { @@ -987,12 +1106,17 @@ export class CodexAcpServer { } } - private beginSessionOpen(sessionId: string): number { + private async beginSessionOpen(sessionId: string): Promise { const generation = this.getSessionGeneration(sessionId); - if (this.sessionIsClosing(sessionId)) { - throw RequestError.invalidRequest(`Session ${sessionId} is closing`); - } - this.sessionOpenGenerations.set(sessionId, generation); + const begin = () => { + if (this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is closing`); + if (this.getSessionGeneration(sessionId) !== generation) throw RequestError.invalidRequest("Session closed while setup was queued"); + const router = this.sessions.get(sessionId)?.subagents; + if (router instanceof StandardSubagentEventRouter) router.pauseForSetup(); + this.sessionOpenGenerations.set(sessionId, generation); + }; + if (this.usesStandardSubagents()) await this.codexAcpClient.enqueueSessionTransition(sessionId, begin); + else begin(); return generation; } @@ -1091,7 +1215,7 @@ export class CodexAcpServer { const existingSessionRequest = request as WithAcpMcpServers | WithAcpMcpServers; const previousInstructions = "sessionId" in request ? this.sessions.get(request.sessionId)?.instructions : undefined; const requestedSessionGeneration = operation === "resume" - ? this.beginSessionOpen(existingSessionRequest.sessionId) + ? await this.beginSessionOpen(existingSessionRequest.sessionId) : null; const knownAccount = await this.checkAuthorization(); const requestedMcpServers = request.mcpServers ?? []; @@ -1172,7 +1296,7 @@ export class CodexAcpServer { } throw err; } - const sessionGeneration = requestedSessionGeneration ?? this.beginSessionOpen(sessionId); + const sessionGeneration = requestedSessionGeneration ?? await this.beginSessionOpen(sessionId); if (!this.sessionOpenCanInstall(sessionId, sessionGeneration)) { settleTrackerReady?.(null); resumeSubscribed = false; @@ -1212,13 +1336,7 @@ export class CodexAcpServer { goalRevision: 0, sessionTitle: null, sessionTitleSource: operation === "resume" ? "unknown" : "unset", - subagents: new CodexSubagentEventRouter( - sessionId, - clientSupportsSubagents(this.clientCapabilities), - new ACPSessionConnection(this.connection, sessionId), - childSessionId => this.reportingConnection.reports.releaseOpen(childSessionId), - () => this.subagentModels(sessionState), - ), + subagents: this.createSubagentRouter(sessionId), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), toolCallReports: this.reportingConnection.reports, @@ -1470,11 +1588,19 @@ export class CodexAcpServer { await baselineEventHandler.handleSessionScopedNotification(event); await this.trackCodexTurnCompletion(sessionState, event, baselineEventHandler); }, - DENY_ALL_APPROVALS, - DENY_ALL_ELICITATIONS, - clientSupportsSubagents(this.clientCapabilities), + this.routeChildApprovals(DENY_ALL_APPROVALS), + this.routeChildElicitations(DENY_ALL_ELICITATIONS), + this.supportsNativeSubagents(), () => {}, - async () => null, + async childThreadId => { + const state = await ready; + return state?.subagents instanceof StandardSubagentEventRouter ? state.subagents.waitForMaterializedSession(childThreadId) : null; + }, + async (childId, itemId, request, turnId) => { + const state = await ready; + return state?.subagents.withUserAction?.(childId, itemId, request, turnId) ?? request(); + }, + event => this.observeSubagentWorkEnd(sessionId, event), ); } @@ -1821,13 +1947,14 @@ export class CodexAcpServer { if (appServer) await appServer; logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor}); if (this.sessionIndex.enabled) { - return await this.sessionIndex.list(params); + const response = await this.sessionIndex.list(params); + return {...response, sessions: response.sessions.filter(session => !this.isRestrictedChild(session.sessionId))}; } await this.checkAuthorization(); const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params)); return { ...response, - sessions: response.sessions.map((session) => { + sessions: response.sessions.filter(session => !this.isRestrictedChild(session.sessionId)).map((session) => { const activeSession = this.sessions.get(session.sessionId); if (!activeSession || activeSession.additionalDirectories.length === 0) { return session; @@ -1851,6 +1978,7 @@ export class CodexAcpServer { this.cancelQueuedV2Prompts(params.sessionId); if (sessionState) { await this.interruptSessionTurn(sessionState, "Close", true); + if (sessionState.subagents instanceof StandardSubagentEventRouter) await sessionState.subagents.finishOutstanding("disconnected"); sessionState.asyncTasks.clear(); } else { logger.log("Close request received for unknown local session", {sessionId: params.sessionId}); @@ -2088,6 +2216,9 @@ export class CodexAcpServer { // closed out, since the old process's EOF drops the notification and no // `turn/completed` ever arrives for it. const previousClient = this.codexAcpClient; + for (const session of this.sessions.values()) { + if (session.subagents instanceof StandardSubagentEventRouter) await session.subagents.finishOutstanding("disconnected"); + } apply(replacement); if (this.initializeRequest === null) { throw new Error("Cannot restart Codex app-server before ACP initialization"); @@ -2113,6 +2244,7 @@ export class CodexAcpServer { // `thread/resume`'s response, and that turn's own `running` would otherwise be // mistaken for the cut-off one and cancelled instead. await previousClient.waitForSessionNotifications(session.sessionId); + if (session.subagents instanceof StandardSubagentEventRouter) session.subagents = this.createSubagentRouter(session.sessionId); if (session.codexReportedRunningTurnId !== null) { session.codexReportedRunningTurnId = null; // the dead process's EOF drops `item/completed` for anything still open on @@ -2269,6 +2401,11 @@ export class CodexAcpServer { // Every session takes its tasks now, before any await, so a task that a restarted app-server starts // meanwhile is not failed with the old ones. for (const session of this.sessions.values()) { + if (session.subagents instanceof StandardSubagentEventRouter) { + session.subagentTransition = session.subagents.finishOutstanding("disconnected") + .catch(error => logger.error("Failed to report child observability loss", error)); + session.subagents = this.createSubagentRouter(session.sessionId); + } session.asyncTasks.finishAll("failed").catch(error => { logger.error("Failed to finish background terminal tasks after the Codex process exited", error); }); @@ -3175,7 +3312,7 @@ export class CodexAcpServer { }> { clientSystemPromptText(request.systemPrompt); const previousInstructions = this.sessions.get(request.sessionId)?.instructions; - const requestedSessionGeneration = this.beginSessionOpen(request.sessionId); + const requestedSessionGeneration = await this.beginSessionOpen(request.sessionId); const loadStartedAt = performance.now(); const graceLeftMs = () => ACCOUNT_READ_LOAD_GRACE_MS - (performance.now() - loadStartedAt); const authStatusVersionAtStart = this.authStatusVersion; @@ -3284,13 +3421,7 @@ export class CodexAcpServer { goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", - subagents: new CodexSubagentEventRouter( - sessionId, - clientSupportsSubagents(this.clientCapabilities), - new ACPSessionConnection(this.connection, sessionId), - childSessionId => this.reportingConnection.reports.releaseOpen(childSessionId), - () => this.subagentModels(sessionState), - ), + subagents: this.createSubagentRouter(sessionId, true), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), toolCallReports: this.reportingConnection.reports, @@ -3364,7 +3495,11 @@ export class CodexAcpServer { this.protocolVersion === 2 ? withoutReviewerPrompts(entryPages) : itemsOfEntries(entryPages), isOpen, ); - if (clientSupportsSubagents(this.clientCapabilities)) { + if (this.usesStandardSubagents()) { + await this.streamStandardAssociations(sessionId, itemPages, sessionState, isOpen); + return; + } + if (this.supportsNativeSubagents()) { await this.streamNativeThreadHistory( sessionId, itemPages, @@ -3554,6 +3689,67 @@ export class CodexAcpServer { } } + /** Association-only replay: child transcripts and recorded outcomes are a separate slice. */ + private async streamStandardAssociations( + sessionId: string, pages: AsyncIterable, state: SessionState, isOpen: () => boolean, + ): Promise { + const router = state.subagents; + if (!(router instanceof StandardSubagentEventRouter)) return; + const session = new ACPSessionConnection(this.connection, sessionId); + const started = new Set(); + const restored = new Set(); + const restore = async (threadId: string, childId: string | null) => { + if (childId === null || restored.has(threadId)) return; + restored.add(threadId); + try { + const metadata = await this.codexAcpClient.appServerClient.threadRead({threadId}); + if (!isOpen()) throw new SessionClosedDuringLoadError(); + const parent = router.parentThreadId(threadId); + if (parent !== undefined) { + const runtime = await this.codexAcpClient.restoreSubagentSubscription(sessionId, parent, metadata.thread); + if (runtime) await router.restoreRuntime(runtime, this.codexAcpClient.appServerClient.runningTurnId(threadId)); + } + } catch (error) { + if (error instanceof SessionClosedDuringLoadError) throw error; + logger.error(`Failed to restore subagent ${threadId}`, error); + } + }; + const unresolved: Array = []; + for await (const items of pages) { + for (const item of items) { + if (!isOpen()) throw new SessionClosedDuringLoadError(); + if (item.type === "subAgentActivity") { + if (isRootAgentPath(item.agentPath) || item.agentThreadId === sessionId) continue; + const childId = await router.announceHistory(item.agentThreadId, item.agentPath); + if (childId === null) unresolved.push(item); + else { + await reportSubagentModel(session, childId, this.subagentModels(state), item.model || undefined); + await restore(item.agentThreadId, childId); + } + continue; + } + if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") { + if (item.senderThreadId === sessionId) for (const child of item.receiverThreadIds) { + if (child.trim() && child !== sessionId) await restore(child, await router.announce(child, sessionId, undefined, item.prompt ?? undefined)); + } + continue; + } + for (const update of await this.createHistoryUpdates(item, state)) { + if (this.protocolVersion === 2) await this.sendReplayMessageStart(session, update, started); + await session.update(update); + } + } + } + for (const item of unresolved) await restore(item.agentThreadId, await router.announceHistory(item.agentThreadId, item.agentPath)); + if (!isOpen()) throw new SessionClosedDuringLoadError(); + if (restored.size > 0) { + const update = {sessionUpdate: "agent_thought_chunk" as const, messageId: `${sessionId}:subagent-history-gap`, + content: {type: "text" as const, text: "Recorded child transcripts are not included in this replay."}}; + if (this.protocolVersion === 2) await this.sendReplayMessageStart(session, update, started); + await session.update(update); + } + } + private async publishThreadHistoryTitle( session: ACPSessionConnection, sessionState: SessionState, @@ -4029,6 +4225,7 @@ export class CodexAcpServer { turnId: turn.turnId, }); try { + await this.interruptStandardChildren(sessionState); await this.requestTurnInterrupt(sessionState, turn.threadId, turn.turnId, requestName); } finally { this.codexAcpClient.resolveTurnInterrupted({ @@ -4119,6 +4316,7 @@ export class CodexAcpServer { requestName: "Cancel" | "Close", resolveInterruptedTurn: boolean, ): Promise { + await this.interruptStandardChildren(sessionState); const turnId = await this.getInterruptibleTurnId(sessionState, requestName); if (!turnId) { return; @@ -4560,6 +4758,9 @@ export class CodexAcpServer { ); promptElicitations = elicitationHandler; const observeInteraction = async (event: ServerNotification): Promise => { + const threadId = (event.params as {threadId?: unknown}).threadId; + if (sessionState.subagents instanceof StandardSubagentEventRouter + && typeof threadId === "string" && threadId !== sessionState.sessionId) return; permissionContext.handleNotification(event); await elicitationHandler.handleNotification(event); }; @@ -4608,11 +4809,13 @@ export class CodexAcpServer { } await this.trackCodexTurnCompletion(sessionState, event, promptEventHandler); }, - approvalHandler, - elicitationHandler, - clientSupportsSubagents(this.clientCapabilities), + this.routeChildApprovals(approvalHandler), + this.routeChildElicitations(elicitationHandler), + this.supportsNativeSubagents(), observeInteraction, - childThreadId => promptEventHandler.waitForNativeSubagentSession(childThreadId)); + childThreadId => promptEventHandler.waitForNativeSubagentSession(childThreadId), + (childId, itemId, request, turnId) => sessionState.subagents.withUserAction?.(childId, itemId, request, turnId) ?? request(), + event => this.observeSubagentWorkEnd(params.sessionId, event)); if (activePrompt.signal.aborted) { return cancelledPromptResponse(); @@ -5264,6 +5467,7 @@ export class CodexAcpServer { } async cancel(params: acp.CancelNotification): Promise { + if (this.isRestrictedChild(params.sessionId)) return; this.activePrompts.get(params.sessionId)?.requestClientCancel(); const sessionState = this.sessions.get(params.sessionId); if (!sessionState) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index f79f1c2f..1227cb3e 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -183,6 +183,7 @@ export class CodexAppServerClient { private readonly threadGoalClearedCaptures = new Map void>>(); private readonly threadSettings = new Map(); private readonly staleTurnIds = new Map>(); + private readonly runningTurnIds = new Map(); private readonly leakedReviewErrors = new Map}>(); /** Ends the notification waits below when the connection is gone, see {@link waitWhileConnected}. */ readonly connectionLoss: ConnectionLoss; @@ -204,8 +205,15 @@ export class CodexAppServerClient { this.mcpOauthCompletions.complete(serverNotification.params); } if (isTurnCompletedNotification(serverNotification)) { + if (serverNotification.params.turn.status !== "inProgress" + && this.runningTurnIds.get(serverNotification.params.threadId) === serverNotification.params.turn.id) { + this.runningTurnIds.delete(serverNotification.params.threadId); + } this.recordTurnCompleted(serverNotification.params); } + if (serverNotification.method === "turn/started") { + this.runningTurnIds.set(serverNotification.params.threadId, serverNotification.params.turn.id); + } if (isCompactionCompletedNotification(serverNotification)) { this.recordCompactionCompleted(serverNotification); } @@ -1031,6 +1039,8 @@ export class CodexAppServerClient { return {...notification, params: {...notification.params, turn: {...turn, status: "completed", error: null}}}; } + runningTurnId(threadId: string): string | undefined { return this.runningTurnIds.get(threadId); } + private recordTurnCompleted(event: TurnCompletedNotification): void { const threadResolvers = this.pendingTurnCompletionResolvers.get(event.threadId); const resolve = threadResolvers?.get(event.turn.id); diff --git a/src/CodexElicitationHandler.ts b/src/CodexElicitationHandler.ts index 2e50cc20..09bfc713 100644 --- a/src/CodexElicitationHandler.ts +++ b/src/CodexElicitationHandler.ts @@ -24,6 +24,7 @@ import { } from "./permissions/mcp"; import type {PermissionPromptContext} from "./permissions/lifecycle"; import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer"; +import {subagentInteractionSignal, subagentToolCallId, withSubagentUserAction} from "./subagents/SubagentInteraction"; import {ElicitationReporter} from "./tool-calls/reporters/ElicitationReporter"; import {isRecord, normalizeJsonObject, normalizeJsonValue, recordOrNull} from "./permissions/json"; import {AIR_CUSTOM_ANSWER_KEY, LEGACY_AIR_CUSTOM_ANSWER_KEY, isAirClient, withAirMeta} from "./AirExtension"; @@ -198,14 +199,15 @@ export class CodexElicitationHandler implements ElicitationHandler { async handleElicitation( params: McpServerElicitationRequestParams ): Promise { + const signal = subagentInteractionSignal(params.threadId, this.cancellationSignal); try { const context = this.createMcpElicitationContext(params); if (this.shouldUseAcpElicitation(params)) { - const response = await this.connection.request( + const response = await withSubagentUserAction(params.threadId, () => this.connection.request( acp.methods.client.elicitation.create, this.buildElicitationRequest(params, context), - this.requestOptions(), - ); + this.requestOptions(signal), + )); const result = this.convertElicitationResponse(response, context); if (params.mode === "url" && result.action === "accept") { this.trackUrlElicitation(params.threadId, params.elicitationId); @@ -221,14 +223,15 @@ export class CodexElicitationHandler implements ElicitationHandler { params.threadId, params, context, - () => this.permissionContext.nextStandaloneMcpToolCallId(params.serverName), + () => subagentToolCallId(params.threadId, params.serverName) + ?? this.permissionContext.nextStandaloneMcpToolCallId(params.serverName), this.renderer, ); - const response = await this.connection.request( + const response = await withSubagentUserAction(params.threadId, () => this.connection.request( acp.methods.client.session.requestPermission, request, - this.requestOptions(), - ); + this.requestOptions(signal), + ), request.toolCall.toolCallId); const result = convertMcpPermissionResponse( response, context.isToolApproval, @@ -287,12 +290,13 @@ export class CodexElicitationHandler implements ElicitationHandler { params: ToolRequestUserInputParams ): Promise { const request = this.buildUserInputRequest(params); + const signal = subagentInteractionSignal(params.threadId, this.cancellationSignal); if (params.autoResolutionMs === null) { - return await this.connection.request( + return await withSubagentUserAction(params.threadId, () => this.connection.request( acp.methods.client.elicitation.create, request, - this.requestOptions(), - ); + this.requestOptions(signal), + ), params.itemId); } const abortController = new AbortController(); @@ -304,22 +308,22 @@ export class CodexElicitationHandler implements ElicitationHandler { resolve(null); }; timeout = setTimeout(resolveWithoutInput, Math.max(0, params.autoResolutionMs ?? 0)); - if (this.cancellationSignal?.aborted) { + if (signal?.aborted) { resolveWithoutInput(); return; } - if (this.cancellationSignal) { - this.cancellationSignal.addEventListener("abort", resolveWithoutInput, { once: true }); + if (signal) { + signal.addEventListener("abort", resolveWithoutInput, { once: true }); removeAbortListener = () => { - this.cancellationSignal?.removeEventListener("abort", resolveWithoutInput); + signal.removeEventListener("abort", resolveWithoutInput); }; } }); - const requestPromise = Promise.resolve(this.connection.request( + const requestPromise = withSubagentUserAction(params.threadId, () => this.connection.request( acp.methods.client.elicitation.create, request, this.requestOptions(abortController.signal), - )); + ), params.itemId, abortController.signal); void requestPromise.catch(() => {}); try { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 85e57abc..b79c366a 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -60,7 +60,8 @@ import { AIR_SESSION_FAILURE_KEY, JETBRAINS_META_KEY, } from "./AirExtension"; -import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; +import type {SubagentEventRouter} from "./subagents/SubagentEventRouter"; +import {StandardSubagentEventRouter} from "./subagents/StandardSubagentEventRouter"; import type {SubagentState} from "./subagents/AcpSubagents"; import {mergeRateLimitSnapshot} from "./RateLimitsMap"; import {AGENT_FILE_CHANGE_REPORT_MAX_DIFF_BYTES} from "./AgentFileChangeReport"; @@ -230,7 +231,7 @@ export class CodexEventHandler { /** The last root turn that ended, for subagent turns that outlive it. */ private latestRootTurnId: string | null = null; private readonly collectTurnDiffs: boolean; - private readonly subagents: CodexSubagentEventRouter; + private readonly subagents: SubagentEventRouter; /** Connection-level `authStatus` sink; the app-server account push feeds it. */ private readonly onAccountUpdated: ((notification: AccountUpdatedNotification) => void) | undefined; @@ -239,7 +240,7 @@ export class CodexEventHandler { sessionState: SessionState, supportsTypedSessionFailures: boolean, sessionFailureEpoch: string, - subagents: CodexSubagentEventRouter, + subagents: SubagentEventRouter, onAccountUpdated: ((notification: AccountUpdatedNotification) => void) | undefined, collectTurnDiffs: boolean, private readonly supportsCompaction: boolean, @@ -321,6 +322,12 @@ export class CodexEventHandler { * instead of entering a turn buffer that will never be flushed. */ async handleSessionScopedNotification(notification: ServerNotification): Promise { + const threadId = (notification.params as {threadId?: unknown}).threadId; + if (this.subagents instanceof StandardSubagentEventRouter && typeof threadId === "string" + && threadId !== this.sessionState.sessionId) { + await this.handleNotification(notification); + return; + } this.recordTerminalTurnError(notification); if (notification.method !== "error") { await this.handleNotification(notification); @@ -417,6 +424,9 @@ export class CodexEventHandler { } async handleNotification(notification: ServerNotification) { + const threadId = (notification.params as {threadId?: unknown}).threadId; + if (this.subagents instanceof StandardSubagentEventRouter && this.subagents.isRetired() + && typeof threadId === "string" && threadId !== this.sessionState.sessionId) return; this.recordTerminalTurnError(notification); if (notification.method === "item/started" && notification.params.threadId === this.sessionState.sessionId @@ -427,10 +437,20 @@ export class CodexEventHandler { if (notification.method === "turn/diff/updated" && notification.params.threadId !== this.sessionState.sessionId) { this.recordChildTurnDiff(notification.params); } + if (this.subagents instanceof StandardSubagentEventRouter && !await this.subagents.prepareNotification(notification)) { + for (const event of this.subagents.takeBufferedNotifications()) await this.handleNotification(event); + return; + } await this.flushPendingErrors(); await this.finishCompactionsForNotification(notification); const closingChildren = this.subagents.closingChildSessions(notification); for (const child of closingChildren) { + if (this.subagents instanceof StandardSubagentEventRouter) { + const turnId = notification.method === "turn/completed" ? notification.params.turn.id : undefined; + for (const update of this.subagents.openToolCalls(child.threadId)?.finishOutstanding(turnId) ?? []) { + await this.session.update(update, child.sessionId); + } + } await this.finishOutstandingCompactions( child.state === "cancelled" ? "cancelled" : "failed", child.sessionId, @@ -556,27 +576,27 @@ export class CodexEventHandler { */ switch (notification.method) { case "item/agentMessage/delta": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); return await this.createTextEvent(notification.params); case "item/plan/delta": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); return this.plans.delta(notification.params.itemId, notification.params.delta); case "item/started": { - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); const update = await this.createItemEvent(notification.params); - this.trackOpenToolCall(notification.params.item); + this.trackOpenToolCall(notification.params.item, notification.params.threadId, notification.params.turnId); return update; } case "item/completed": - this.completeRetryIncidentOnTurnProgress(); - this.sessionState.openToolCalls.complete(notification.params.item.id); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); + this.openToolCalls(notification.params.threadId).complete(notification.params.item.id); return await this.completeItemEvent(notification.params); case "turn/plan/updated": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); return await this.updatePlan(notification.params); case "turn/diff/updated": if (notification.params.threadId === this.sessionState.sessionId) { - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); if (!this.disposed && this.collectTurnDiffs) { if (Buffer.byteLength(notification.params.diff, "utf8") > AGENT_FILE_CHANGE_REPORT_MAX_DIFF_BYTES) { this.turnDiffs.delete(notification.params.turnId); @@ -649,10 +669,10 @@ export class CodexEventHandler { closed: true, }); case "item/commandExecution/outputDelta": - this.completeRetryIncidentOnTurnProgress(); - return this.renderer.render(this.itemStreams.commands.outputDelta(notification.params.itemId, notification.params.delta)); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); + return this.renderer.render(this.streamsFor(notification.params.threadId).commands.outputDelta(notification.params.itemId, notification.params.delta)); case "item/mcpToolCall/progress": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); // AIR does not show MCP progress. if (this.renderer.capabilities.airToolCallContract) return null; return this.renderer.render(McpToolReporter.progress( @@ -674,9 +694,9 @@ export class CodexEventHandler { case "deprecationNotice": return this.createDeprecationNoticeEvent(notification.params); case "item/autoApprovalReview/started": - return this.renderer.render(this.itemStreams.guardianReviews.started(notification.params)); + return this.renderer.render(this.streamsFor(notification.params.threadId).guardianReviews.started(notification.params)); case "item/autoApprovalReview/completed": - return this.renderer.render(this.itemStreams.guardianReviews.completed(notification.params)); + return this.renderer.render(this.streamsFor(notification.params.threadId).guardianReviews.completed(notification.params)); case "thread/compacted": return this.supportsCompaction ? this.sessionState.compactions.completeLegacy( @@ -684,13 +704,13 @@ export class CodexEventHandler { ) : this.createContextCompactedEvent(); case "item/reasoning/summaryTextDelta": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); return this.createReasoningDeltaEvent(notification.params); case "item/reasoning/textDelta": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); return this.createReasoningDeltaEvent(notification.params); case "item/reasoning/summaryPartAdded": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); return this.createReasoningSectionBreakEvent(notification.params); case "model/rerouted": return this.createModelReroutedEvent(notification.params); @@ -703,7 +723,7 @@ export class CodexEventHandler { case "thread/goal/cleared": return this.createThreadGoalClearedEvent(notification.params); case "item/commandExecution/terminalInteraction": - return this.renderer.render(this.itemStreams.commands.terminalInput( + return this.renderer.render(this.streamsFor(notification.params.threadId).commands.terminalInput( notification.params.itemId, notification.params.stdin, )); @@ -781,7 +801,7 @@ export class CodexEventHandler { } private async createTextEvent(event: AgentMessageDeltaNotification): Promise { - const phase = this.itemStreams.agentMessagePhases.get(event.itemId) ?? null; + const phase = this.streamsFor(event.threadId).agentMessagePhases.get(event.itemId) ?? null; const meta = createMessagePhaseMeta(phase, this.sessionState.clientCapabilities.airClient); return createAgentTextMessageChunk(event.delta, event.itemId, meta); } @@ -853,12 +873,12 @@ export class CodexEventHandler { private createReasoningDeltaEvent( event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification ): UpdateSessionEvent { - this.itemStreams.seenReasoningDeltaItemIds.add(event.itemId); + this.streamsFor(event.threadId).seenReasoningDeltaItemIds.add(event.itemId); return this.createAgentThoughtEvent(event.delta, event.itemId); } private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent { - this.itemStreams.seenReasoningDeltaItemIds.add(event.itemId); + this.streamsFor(event.threadId).seenReasoningDeltaItemIds.add(event.itemId); return this.createAgentThoughtEvent("\n\n", event.itemId); } @@ -874,7 +894,7 @@ export class CodexEventHandler { this.sessionState.clientCapabilities.diffPatchFormat, )); case "commandExecution": - return this.renderer.render(this.itemStreams.commands.started(event.item)); + return this.renderer.render(this.streamsFor(event.threadId).commands.started(event.item)); case "mcpToolCall": return this.renderer.render(McpToolReporter.started(event.item)); case "dynamicToolCall": @@ -882,15 +902,15 @@ export class CodexEventHandler { case "webSearch": return this.renderer.render(WebSearchReporter.started(event.item)); case "imageView": - this.itemStreams.emittedImageViewItems.add(event.item.id); + this.streamsFor(event.threadId).emittedImageViewItems.add(event.item.id); return this.renderer.render(ImageViewReporter.viewed(event.item)); case "imageGeneration": - this.itemStreams.activeImageGenerationItems.add(event.item.id); + this.streamsFor(event.threadId).activeImageGenerationItems.add(event.item.id); return this.renderer.render(ImageGenerationReporter.started(event.item)); case "collabAgentToolCall": return this.renderer.render(this.subagents.legacyCollaborationStarted(event.item)); case "agentMessage": - this.rememberAgentMessagePhase(event.item); + this.rememberAgentMessagePhase(event.item, event.threadId); return null; case "contextCompaction": return this.supportsCompaction @@ -922,18 +942,18 @@ export class CodexEventHandler { case "mcpToolCall": return this.renderer.render(McpToolReporter.completed(event.item)); case "commandExecution": - return this.renderer.render(this.itemStreams.commands.completed(event.item, true)); + return this.renderer.render(this.streamsFor(event.threadId).commands.completed(event.item, true)); case "imageView": - if (this.itemStreams.emittedImageViewItems.delete(event.item.id)) { + if (this.streamsFor(event.threadId).emittedImageViewItems.delete(event.item.id)) { return null; } return this.renderer.render(ImageViewReporter.viewed(event.item)); case "imageGeneration": - return this.renderer.render(this.itemStreams.activeImageGenerationItems.delete(event.item.id) + return this.renderer.render(this.streamsFor(event.threadId).activeImageGenerationItems.delete(event.item.id) ? ImageGenerationReporter.completed(event.item) : ImageGenerationReporter.whole(event.item)); case "reasoning": - if (this.itemStreams.seenReasoningDeltaItemIds.delete(event.item.id)) { + if (this.streamsFor(event.threadId).seenReasoningDeltaItemIds.delete(event.item.id)) { return null; } return this.createCompletedReasoningEvent(event.item); @@ -942,7 +962,7 @@ export class CodexEventHandler { case "collabAgentToolCall": return this.renderer.render(this.subagents.legacyCollaborationCompleted(event.item)); case "agentMessage": - this.rememberAgentMessagePhase(event.item); + this.rememberAgentMessagePhase(event.item, event.threadId); return null; case "plan": return await this.createCompletedPlanEvent(event.item); @@ -967,8 +987,13 @@ export class CodexEventHandler { } } - private rememberAgentMessagePhase(item: ThreadItem & { type: "agentMessage" }): void { - this.itemStreams.agentMessagePhases.set(item.id, item.phase); + private streamsFor(threadId: string): CodexItemStreamState { + return this.subagents instanceof StandardSubagentEventRouter + ? this.subagents.itemStreams(threadId) ?? this.itemStreams : this.itemStreams; + } + + private rememberAgentMessagePhase(item: ThreadItem & { type: "agentMessage" }, threadId: string): void { + this.streamsFor(threadId).agentMessagePhases.set(item.id, item.phase); } private createCompletedReasoningEvent(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent | null { @@ -1006,10 +1031,15 @@ export class CodexEventHandler { ); } - private trackOpenToolCall(item: ThreadItem): void { + private openToolCalls(threadId: string) { + return this.subagents instanceof StandardSubagentEventRouter + ? this.subagents.openToolCalls(threadId) ?? this.sessionState.openToolCalls : this.sessionState.openToolCalls; + } + + private trackOpenToolCall(item: ThreadItem, threadId: string, turnId: string): void { if (!PLAIN_TOOL_CALL_ITEM_TYPES.has(item.type)) return; const hasTerminal = item.type === "commandExecution" && commandUsesTerminal(item); - this.sessionState.openToolCalls.start(item.id, hasTerminal); + this.openToolCalls(threadId).start(item.id, hasTerminal, turnId); } private async updatePlan(event: TurnPlanUpdatedNotification): Promise { @@ -1138,7 +1168,8 @@ export class CodexEventHandler { * a new incident instead of overwriting the historical reconnect entry. Terminal errors remain active so * duplicate late notifications cannot append duplicate transcript rows. */ - private completeRetryIncidentOnTurnProgress(): void { + private completeRetryIncidentOnTurnProgress(threadId: string): void { + if (this.subagents instanceof StandardSubagentEventRouter && threadId !== this.sessionState.sessionId) return; const turnId = this.sessionState.currentTurnId; if (turnId === null) return; const activeId = this.activeFailureIdByScope.get(turnId); diff --git a/src/CodexSessionToolCalls.ts b/src/CodexSessionToolCalls.ts index 5b5be5d6..129eef46 100644 --- a/src/CodexSessionToolCalls.ts +++ b/src/CodexSessionToolCalls.ts @@ -9,10 +9,10 @@ import type {UpdateSessionEvent} from "./ACPSessionConnection"; * found and failed out. */ export class CodexSessionToolCalls { - private readonly open = new Map(); + private readonly open = new Map(); - start(itemId: string, hasTerminal: boolean): void { - this.open.set(itemId, {hasTerminal}); + start(itemId: string, hasTerminal: boolean, turnId?: string): void { + this.open.set(itemId, {hasTerminal, ...(turnId !== undefined ? {turnId} : {})}); } complete(itemId: string): void { @@ -25,9 +25,10 @@ export class CodexSessionToolCalls { } /** Fails every still-open tool call and ends its terminal, if it had one. */ - finishOutstanding(): UpdateSessionEvent[] { + finishOutstanding(turnId?: string): UpdateSessionEvent[] { const updates: UpdateSessionEvent[] = []; - for (const [itemId, {hasTerminal}] of this.open) { + for (const [itemId, {hasTerminal, turnId: owner}] of this.open) { + if (turnId !== undefined && owner !== undefined && owner !== turnId) continue; updates.push({ sessionUpdate: "tool_call_update", toolCallId: itemId, @@ -38,8 +39,8 @@ export class CodexSessionToolCalls { }, } : {}), }); + this.open.delete(itemId); } - this.open.clear(); return updates; } } diff --git a/src/__tests__/AcpResponseDelivery.test.ts b/src/__tests__/AcpResponseDelivery.test.ts new file mode 100644 index 00000000..ba956e9a --- /dev/null +++ b/src/__tests__/AcpResponseDelivery.test.ts @@ -0,0 +1,43 @@ +import {describe, expect, it} from "vitest"; +import type {AnyWireMessage} from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import {AcpResponseDelivery} from "../AcpResponseDelivery"; + +describe("ACP response delivery boundary", () => { + it("waits for a whole batch write and ignores notification IDs", async () => { + const delivery = new AcpResponseDelivery(); + const observed: string[] = []; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const stream = delivery.observe({ + readable: new ReadableStream(), + writable: new WritableStream({write: () => held}), + }); + delivery.afterWrite(1, () => { observed.push("first"); }); + delivery.afterWrite("two", () => { observed.push("second"); }); + const writer = stream.writable.getWriter(); + const write = writer.write([{jsonrpc: "2.0", id: 1, result: {}}, {jsonrpc: "2.0", id: "two", error: {code: -32603, message: "Failed"}}]); + await Promise.resolve(); + expect(observed).toEqual([]); + release(); + await write; + expect(observed).toEqual(["first", "second"]); + await writer.write({jsonrpc: "2.0", method: "session/update", params: {}}); + expect(observed).toEqual(["first", "second"]); + await writer.close(); + }); + + it("never releases snapshots on a failed write or a disposed connection", async () => { + const delivery = new AcpResponseDelivery(); + let released = false; + delivery.afterWrite(1, () => { released = true; }); + const stream = delivery.observe({ + readable: new ReadableStream(), + writable: new WritableStream({write: () => { throw new Error("Write failed"); }}), + }); + await expect(stream.writable.getWriter().write({jsonrpc: "2.0", id: 1, result: {}})).rejects.toThrow("Write failed"); + expect(released).toBe(false); + delivery.dispose(); + delivery.afterWrite(2, () => { released = true; }); + expect(released).toBe(false); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-cancel.json b/src/__tests__/CodexACPAgent/data/live-core-v1-cancel.json new file mode 100644 index 00000000..ad34b19f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-cancel.json @@ -0,0 +1,32 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-identity.json b/src/__tests__/CodexACPAgent/data/live-core-v1-identity.json new file mode 100644 index 00000000..d2bc61d4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-identity.json @@ -0,0 +1,75 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "First answer", + "type": "text" + }, + "messageId": "first", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Second answer", + "type": "text" + }, + "messageId": "second", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-model.json b/src/__tests__/CodexACPAgent/data/live-core-v1-model.json new file mode 100644 index 00000000..fd5ec840 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-model.json @@ -0,0 +1,34 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "configOptions": [ + { + "currentValue": "actual-child-model", + "options": [ + { + "value": "actual-child-model", + "name": "actual-child-model", + "description": null + } + ], + "type": "select", + "id": "model", + "name": "Model", + "description": "Model Codex uses for the subagent", + "category": "model" + } + ], + "sessionUpdate": "config_option_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-nested.json b/src/__tests__/CodexACPAgent/data/live-core-v1-nested.json new file mode 100644 index 00000000..855fc1a8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-nested.json @@ -0,0 +1,41 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:coordinator", + "title": "Coordinator", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Nested answer", + "type": "text" + }, + "messageId": "nested", + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-ownership-foreign.json b/src/__tests__/CodexACPAgent/data/live-core-v1-ownership-foreign.json new file mode 100644 index 00000000..b8cd8e42 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-ownership-foreign.json @@ -0,0 +1,11 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-ownership-owned.json b/src/__tests__/CodexACPAgent/data/live-core-v1-ownership-owned.json new file mode 100644 index 00000000..7f4a3d4d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-ownership-owned.json @@ -0,0 +1,41 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "requires_action" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-terminal.json b/src/__tests__/CodexACPAgent/data/live-core-v1-terminal.json new file mode 100644 index 00000000..d3b95c93 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-terminal.json @@ -0,0 +1,94 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "terminal", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "terminal", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "_meta": { + "terminal_info": { + "cwd": "/workspace", + "terminal_id": "terminal" + } + }, + "sessionUpdate": "tool_call" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "terminal", + "_meta": { + "terminal_output_delta": { + "data": "Still streaming", + "terminal_id": "terminal" + } + }, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "terminal", + "status": "completed", + "_meta": { + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "terminal" + } + }, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v1-unknown.json b/src/__tests__/CodexACPAgent/data/live-core-v1-unknown.json new file mode 100644 index 00000000..92701877 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v1-unknown.json @@ -0,0 +1,42 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "unknown" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-cancel.json b/src/__tests__/CodexACPAgent/data/live-core-v2-cancel.json new file mode 100644 index 00000000..ee412595 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-cancel.json @@ -0,0 +1,47 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "stopReason": "end_turn", + "state": "idle", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-identity.json b/src/__tests__/CodexACPAgent/data/live-core-v2-identity.json new file mode 100644 index 00000000..566afb07 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-identity.json @@ -0,0 +1,105 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "first", + "content": { + "text": "First answer", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "stopReason": "end_turn", + "state": "idle", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "second", + "content": { + "text": "Second answer", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "stopReason": "end_turn", + "state": "idle", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-model.json b/src/__tests__/CodexACPAgent/data/live-core-v2-model.json new file mode 100644 index 00000000..5184c410 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-model.json @@ -0,0 +1,34 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "configOptions": [ + { + "currentValue": "actual-child-model", + "options": [ + { + "value": "actual-child-model", + "name": "actual-child-model", + "description": null + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the subagent", + "category": "model" + } + ], + "sessionUpdate": "config_option_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-nested.json b/src/__tests__/CodexACPAgent/data/live-core-v2-nested.json new file mode 100644 index 00000000..d01d72d7 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-nested.json @@ -0,0 +1,48 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:coordinator", + "title": "Coordinator", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "nested", + "content": { + "text": "Nested answer", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-ownership-foreign.json b/src/__tests__/CodexACPAgent/data/live-core-v2-ownership-foreign.json new file mode 100644 index 00000000..b8cd8e42 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-ownership-foreign.json @@ -0,0 +1,11 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-ownership-owned.json b/src/__tests__/CodexACPAgent/data/live-core-v2-ownership-owned.json new file mode 100644 index 00000000..19b146e2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-ownership-owned.json @@ -0,0 +1,62 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "idle", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "requires_action", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "requires_action" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-terminal.json b/src/__tests__/CodexACPAgent/data/live-core-v2-terminal.json new file mode 100644 index 00000000..cec2a779 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-terminal.json @@ -0,0 +1,110 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "terminal", + "command": "sleep 12", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "terminal", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "terminal", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "terminal", + "data": "U3RpbGwgc3RyZWFtaW5n", + "sessionUpdate": "terminal_output_chunk" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "terminal", + "exitStatus": { + "exitCode": 0, + "signal": null + }, + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "terminal", + "status": "completed", + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/live-core-v2-unknown.json b/src/__tests__/CodexACPAgent/data/live-core-v2-unknown.json new file mode 100644 index 00000000..b0701f1d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/live-core-v2-unknown.json @@ -0,0 +1,64 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "stopReason": "end_turn", + "state": "idle", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "end_turn", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "state": "unknown", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "unknown" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/session-transition-queue.test.ts b/src/__tests__/CodexACPAgent/session-transition-queue.test.ts new file mode 100644 index 00000000..c586bba5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-transition-queue.test.ts @@ -0,0 +1,25 @@ +import {expect, it, vi} from "vitest"; +import {connectSession, sessionId} from "./v2-prompt-harness"; + +it("waits for async transition completion and keeps the notification lane usable after rejection", async () => { + const h = await connectSession(); + const client = h.agent["codexAcpClient"]; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const order: string[] = []; + const first = client.enqueueSessionTransition(sessionId, async () => { + order.push("first-start"); + await held; + order.push("first-end"); + throw new Error("Transition failed"); + }); + const failed = expect(first).rejects.toThrow("Transition failed"); + const second = client.enqueueSessionTransition(sessionId, async () => { order.push("second"); }); + try { + await vi.waitFor(() => expect(order).toEqual(["first-start"])); + release(); + await failed; + await second; + expect(order).toEqual(["first-start", "first-end", "second"]); + } finally { release(); h.connection.close(); } +}); diff --git a/src/__tests__/CodexACPAgent/standard-subagent-live.test.ts b/src/__tests__/CodexACPAgent/standard-subagent-live.test.ts new file mode 100644 index 00000000..fc945cd3 --- /dev/null +++ b/src/__tests__/CodexACPAgent/standard-subagent-live.test.ts @@ -0,0 +1,440 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import type {ServerNotification} from "../../app-server"; +import type {ThreadItem} from "../../app-server/v2"; +import { + agentMessageDelta, commandExecutionItem, connectSession, codexResponse, createThread, createTurn, cwd, + itemStarted, sessionId, turnStarted, turnCompleted, userMessageItem, type PromptSession, +} from "./v2-prompt-harness"; +import {expectConformingV2SessionUpdates} from "./v2-session-update-guard"; +import {subagentProtocol} from "../../subagents/AcpSubagents"; +import {AIR_NATIVE_SUBAGENT_SESSIONS_KEY, withAirMeta} from "../../AirExtension"; +import {GOAL_CONTROL_METHOD, SESSION_STEERING_METHOD, LEGACY_SET_SESSION_MODEL_METHOD} from "../../AcpExtensions"; +import {SESSION_RENAME_METHOD, SESSION_ARCHIVE_METHOD, SESSION_UNARCHIVE_METHOD} from "../../SessionIndex"; +import {ASYNC_TASK_STOP_METHOD} from "../../async-tasks/AsyncTaskExtension"; + +const worker = "worker"; +const childId = `${sessionId}:subagent:${worker}`; +const activityItem = (id = worker, path = "/root/worker"): Extract => + ({type: "subAgentActivity", model: null, reasoningEffort: null, id: `activity-${id}`, + agentThreadId: id, agentPath: path, kind: "started"}); +const onWorker = (event: ServerNotification, id = worker): ServerNotification => + ({...event, params: {...event.params, threadId: id}} as ServerNotification); + +async function flush(h: PromptSession) { + await h.waitForNotifications(); + await h.request("session/list", {}); +} + +function history(h: PromptSession, items: ThreadItem[] = [], parent: string | null = sessionId, loaded = true) { + const resumes: string[] = []; + const thread = (id: string) => ({...createThread(), id, + parentThreadId: id === worker ? parent : null, + status: id === worker && !loaded ? {type: "notLoaded" as const} : {type: "idle" as const}}); + h.setCodexResponse("thread/resume", async params => { + const id = (params as {threadId: string}).threadId; + resumes.push(id); + return {...codexResponse("thread/start") as object, thread: thread(id)}; + }); + h.setCodexResponse("thread/read", async params => { + const id = (params as {threadId: string}).threadId; + return {thread: {...thread(id), turns: id === sessionId + ? [{...createTurn("completed"), itemsView: "full", items}] : []}}; + }); + return resumes; +} + +it("selects standard and AIR contracts independently", () => { + const air = {_meta: withAirMeta(undefined, "capabilities", [AIR_NATIVE_SUBAGENT_SESSIONS_KEY])}; + expect([subagentProtocol(null, 1), subagentProtocol({subagents: {}}, 1), subagentProtocol(null, 2), + subagentProtocol(air, 1), subagentProtocol(air, 2)]).toEqual(["hidden", "standard", "standard", "air", "air"]); +}); + +describe.each([1, 2] as const)("ACP v%s live standard subagents", version => { + let h: PromptSession; + afterEach(async () => { + if (h) { await flush(h); h.connection.close(); } + expectConformingV2SessionUpdates(); + }); + async function connect(options: Parameters[1] = {}) { + h = await connectSession(version, {v1ClientCapabilities: {subagents: {}}, ...options}); + } + const load = () => h.request(version === 1 ? "session/load" : "session/resume", { + sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + const childUpdates = () => h.scopedUpdates.filter(entry => + entry.sessionId.includes(":subagent:") || entry.update.sessionUpdate === "subagent_update"); + async function snapshot(name: string) { + await flush(h); + await expect(JSON.stringify(childUpdates(), null, 2)).toMatchFileSnapshot(`data/live-core-v${version}-${name}.json`); + } + const approval = (itemId: string, turnId = "child-turn") => h.triggerApproval("item/commandExecution/requestApproval", { + threadId: worker, turnId, itemId, command: "echo hello", cwd, commandActions: [], + reason: null, availableDecisions: ["accept", "decline"], + }); + + it("preserves a child identity across idle and later work without publishing agent input as human input", async () => { + await connect(); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("child-turn"))); + h.emit(onWorker(itemStarted(userMessageItem(null, "Agent delegation"), "child-turn"))); + h.emit(onWorker(agentMessageDelta("First answer", "child-turn", "first"))); + h.emit(onWorker(turnCompleted("child-turn"))); + h.emit(onWorker(turnStarted("next-turn"))); + h.emit(onWorker(agentMessageDelta("Second answer", "next-turn", "second"))); + h.emit(onWorker(turnCompleted("next-turn"))); + await snapshot("identity"); + expect(childUpdates().filter(entry => entry.update.sessionUpdate === "subagent_update" && "capabilities" in entry.update)).toHaveLength(1); + expect(childUpdates().some(entry => entry.update.sessionUpdate === "user_message_chunk")).toBe(false); + }); + + it("waits for immediate parentage and announces ancestors before child traffic", async () => { + await connect(); + h.emit(itemStarted(activityItem(worker, "/root/coordinator/worker"))); + h.emit(onWorker(turnStarted("child-turn"))); + h.emit(onWorker(agentMessageDelta("Nested answer", "child-turn", "nested"))); + await flush(h); + expect(childUpdates()).toEqual([]); + h.emit(itemStarted(activityItem("coordinator", "/root/coordinator"))); + await snapshot("nested"); + const known = new Set([sessionId]); + for (const entry of childUpdates()) { + expect(known.has(entry.sessionId)).toBe(true); + if (entry.update.sessionUpdate === "subagent_update" && typeof entry.update.sessionId === "string") known.add(entry.update.sessionId); + } + }); + + it("rejects all child mutations and raw-provider-ID bypasses", async () => { + await connect(); + h.emit(itemStarted(activityItem())); + await flush(h); + const methods: Array<[string, Record]> = [ + ["session/prompt", {prompt: [{type: "text", text: "No"}]}], + ["session/close", {}], ["session/delete", {}], ["session/fork", {cwd, mcpServers: []}], + ["session/set_config_option", {configId: "model", value: "gpt-5", ...(version === 2 ? {type: "id"} : {})}], + ["session/resume", {cwd, mcpServers: []}], + [SESSION_RENAME_METHOD, {title: "No"}], [SESSION_ARCHIVE_METHOD, {}], [SESSION_UNARCHIVE_METHOD, {}], + [SESSION_STEERING_METHOD, {prompt: [{type: "text", text: "No"}]}], + [GOAL_CONTROL_METHOD, {action: "clear"}], [ASYNC_TASK_STOP_METHOD, {asyncTaskId: "task"}], + ]; + if (version === 1) methods.push(["session/load", {cwd, mcpServers: []}], ["session/set_mode", {modeId: "agent"}], + [LEGACY_SET_SESSION_MODEL_METHOD, {modelId: "gpt-5"}]); + for (const id of [childId, worker]) for (const [method, params] of methods) { + await expect(h.request(method, {sessionId: id, ...params}), `${method}: ${id}`).rejects.toMatchObject({code: -32600}); + } + await h.agent.cancel({sessionId: childId}); + expect(h.turnInterruptCalls()).toEqual([]); + }); + + it("keeps permissions and accepted URL completions alive across parent replay", async () => { + await connect({ + v1ClientCapabilities: {subagents: {}, elicitation: {url: {}}}, + clientCapabilities: {elicitation: {url: {}}}, + onRequestPermission: async request => ({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}), + onElicitation: async () => ({action: "accept", content: null}), + }); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("child-turn"))); + await flush(h); + expect(await approval("before")).toEqual({decision: "accept"}); + expect(await h.triggerApproval("mcpServer/elicitation/request", { + threadId: worker, turnId: "child-turn", serverName: "auth", mode: "url", _meta: null, + message: "Authorize", url: "https://example.com/authorize", elicitationId: "authorization", + })).toMatchObject({action: "accept"}); + history(h); + await load(); + expect(await approval("after")).toEqual({decision: "accept"}); + h.emit({method: "serverRequest/resolved", params: {threadId: worker, requestId: "authorization-request"}}); + await flush(h); + expect(h.transcript).toContainEqual({elicitationComplete: {elicitationId: "authorization"}}); + expect(h.transcript.filter(entry => "permissionRequest" in entry).map(entry => + "permissionRequest" in entry ? entry.permissionRequest.sessionId : null)).toEqual([childId, childId]); + }); + + it("withdraws obsolete requests locally even if the client returns an accepted late reply", async () => { + let accept!: () => void; + await connect({onRequestPermission: request => new Promise(resolve => { + accept = () => resolve({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}); + })}); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("child-turn"))); + await flush(h); + const pending = approval("late"); + await vi.waitFor(() => expect(accept).toBeTypeOf("function")); + h.emit(onWorker(turnCompleted("child-turn"))); + await expect(pending).resolves.toEqual({decision: "cancel"}); + accept(); + }); + + it("retains blocked work when a new child turn requests approval during parent replay", async () => { + let answer!: () => void; + await connect({onRequestPermission: request => new Promise(resolve => { + answer = () => resolve({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}); + })}); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("old-turn"))); + await flush(h); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let reading = false; + history(h); + const delay = async (operation: Promise) => { + const result = await operation; + reading = true; + await held; + return result; + }; + if (version === 1) { + const operation = h.agent.loadSession.bind(h.agent); + vi.spyOn(h.agent, "loadSession").mockImplementation(params => delay(operation(params))); + } else { + const operation = h.agent.resumeSessionV2.bind(h.agent); + vi.spyOn(h.agent, "resumeSessionV2").mockImplementation(params => delay(operation(params))); + } + const replay = load(); + void replay.catch(() => {}); + try { + await vi.waitFor(() => expect(reading).toBe(true)); + h.emit(onWorker(turnStarted("new-turn"))); + const pending = approval("during-replay", "new-turn"); + await vi.waitFor(() => expect(answer).toBeTypeOf("function")); + release(); + await replay; + await vi.waitFor(() => { + const latest = childUpdates().flatMap(entry => { + if (version === 2 && entry.sessionId === childId && entry.update.sessionUpdate === "state_update") return [entry.update.state]; + if (version === 1 && entry.update.sessionUpdate === "subagent_update" + && entry.update.state && typeof entry.update.state === "object") return [(entry.update.state as {state: string}).state]; + return []; + }).at(-1); + expect(latest).toBe("requires_action"); + }); + answer(); + await expect(pending).resolves.toEqual({decision: "accept"}); + } finally { release(); answer?.(); } + }); + + it.each([sessionId, "another-root", null] as const)("restores only verified runtime children (parent: %s)", async parent => { + await connect({onRequestPermission: request => ({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}})}); + const resumes = history(h, [activityItem()], parent); + await load(); + await flush(h); + expect(resumes).toEqual(parent === sessionId ? [sessionId, worker] : [sessionId]); + h.emit(onWorker(turnStarted("child-turn"))); + await flush(h); + if (parent === sessionId) expect(await approval("restored")).toEqual({decision: "accept"}); + else expect(await approval("foreign")).toEqual({decision: "cancel"}); + await snapshot(`ownership-${parent === sessionId ? "owned" : "foreign"}`); + }); + + it("keeps command rendering across parent attachment changes", async () => { + await connect(); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("child-turn"))); + h.emit(onWorker(itemStarted(commandExecutionItem("terminal"), "child-turn"))); + await flush(h); + history(h); + await load(); + h.emit({method: "item/commandExecution/outputDelta", params: {threadId: worker, turnId: "child-turn", itemId: "terminal", delta: "Still streaming"}}); + h.emit({method: "item/completed", params: {threadId: worker, turnId: "child-turn", completedAtMs: 0, + item: {...commandExecutionItem("terminal", "completed"), exitCode: 0, aggregatedOutput: "Still streaming"}}}); + await snapshot("terminal"); + }); + + it("delivers a previously inactive owned child's next turn without another spawn", async () => { + await connect(); + const resumes = history(h, [activityItem()], sessionId, false); + await load(); + await flush(h); + expect(resumes).toEqual([sessionId]); + h.emit(onWorker(turnStarted("awakened"))); + h.emit(onWorker(agentMessageDelta("Awake", "awakened", "awake"))); + await flush(h); + expect(childUpdates().some(entry => entry.sessionId === childId + && entry.update.sessionUpdate === "agent_message_chunk" && entry.update.messageId === "awake")).toBe(true); + }); + + it("tracks a restored child's approval even when its turn started before attachment", async () => { + let answer!: () => void; + await connect({onRequestPermission: request => new Promise(resolve => { + answer = () => resolve({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}); + })}); + history(h, [activityItem()]); + await load(); + await flush(h); + const pending = approval("restored-without-start", "already-running"); + try { + await vi.waitFor(() => expect(answer).toBeTypeOf("function")); + await flush(h); + expect(childUpdates().some(entry => entry.update.sessionUpdate === "subagent_update" + && entry.update.state && typeof entry.update.state === "object" + && (entry.update.state as {state: string}).state === "requires_action")).toBe(true); + answer(); + await expect(pending).resolves.toEqual({decision: "accept"}); + } finally { answer?.(); } + }); + + it("restricts raw child IDs before a backpressured announcement is published", async () => { + await connect(); + const client = h.agent["codexAcpClient"]; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const blocked = client.enqueueSessionTransition(sessionId, () => held); + try { + h.emit(itemStarted(activityItem())); + await expect(h.request("session/close", {sessionId: worker})).rejects.toMatchObject({code: -32600}); + expect(childUpdates()).toEqual([]); + } finally { release(); } + await blocked; + await flush(h); + }); + + it("cascades parent cancellation without inventing a child ending", async () => { + await connect(); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("child-turn"))); + await flush(h); + const before = childUpdates().length; + await h.cancel(); + await vi.waitFor(() => expect(h.turnInterruptCalls()).toContainEqual({threadId: worker, turnId: "child-turn"})); + await flush(h); + expect(childUpdates().slice(before)).toEqual([]); + h.emit(onWorker(turnCompleted("child-turn"))); + await snapshot("cancel"); + }); + + it("keeps lost observability unknown when an old completion is repeated", async () => { + await connect(); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("old-turn"))); + h.emit(onWorker(turnCompleted("old-turn"))); + h.emit(onWorker(turnStarted("new-turn"))); + h.emit({method: "thread/status/changed", params: {threadId: worker, status: {type: "notLoaded"}}}); + await flush(h); + const boundary = childUpdates().length; + h.emit(onWorker(turnCompleted("old-turn"))); + await flush(h); + expect(childUpdates().slice(boundary)).toEqual([]); + await snapshot("unknown"); + }); + + it("keeps observed models read-only and child-scoped using the existing model reporter", async () => { + await connect(); + const parent = h.agent.getSessionState(sessionId).currentModelId; + h.emit(itemStarted({...activityItem(), model: "actual-child-model"})); + await flush(h); + const config = childUpdates().find(entry => entry.sessionId === childId && entry.update.sessionUpdate === "config_option_update"); + expect(config).toBeDefined(); + expect(h.agent.getSessionState(sessionId).currentModelId).toBe(parent); + await snapshot("model"); + }); + + it("announces with the latest observed settings model without replaying older buffered settings", async () => { + await connect(); + h.emit(itemStarted({...activityItem(worker, "/root/coordinator/worker"), model: "activity-model"})); + const settings = (model: string) => ({ + disabledPluginIds: [], cwd, approvalPolicy: "on-request" as const, approvalsReviewer: "user" as const, + sandboxPolicy: {type: "readOnly" as const, networkAccess: false}, activePermissionProfile: null, + model, modelProvider: "test", serviceTier: null, effort: null, summary: null, personality: null, + collaborationMode: {mode: "default" as const, settings: {model, reasoning_effort: null, developer_instructions: null}}, + }); + h.emit({method: "thread/settings/updated", params: {threadId: worker, threadSettings: settings("older-settings")}}); + h.emit({method: "thread/settings/updated", params: {threadId: worker, threadSettings: settings("latest-settings")}}); + await flush(h); + expect(childUpdates()).toEqual([]); + h.emit(itemStarted(activityItem("coordinator", "/root/coordinator"))); + await flush(h); + const options = childUpdates().flatMap(entry => entry.sessionId === childId && entry.update.sessionUpdate === "config_option_update" + ? [(entry.update as {configOptions: Array<{currentValue: string}>}).configOptions[0]!.currentValue] : []); + expect(options).toEqual(["latest-settings"]); + }); + + it("replays associations and parent history without claiming complete child history", async () => { + await connect(); + const item: ThreadItem = {type: "agentMessage", id: "parent-answer", text: "Parent history", phase: null, + memoryCitation: null, delivery: null, questions: null}; + history(h, [activityItem(), item], "foreign-root"); + await load(); + await flush(h); + expect(h.scopedUpdates.some(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "agent_message_chunk" + && entry.update.messageId === "parent-answer")).toBe(true); + expect(h.scopedUpdates.some(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "agent_thought_chunk" + && entry.update.messageId === `${sessionId}:subagent-history-gap`)).toBe(true); + expect(childUpdates().filter(entry => entry.sessionId === childId)).toEqual([]); + }); + + it.skipIf(version !== 2)("finishes two same-parent setups in one batch without deadlock", async () => { + await connect(); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("child-turn"))); + await flush(h); + history(h, [activityItem()]); + await h.requestBatch([ + {method: "session/resume", params: {sessionId, cwd, replayFrom: {type: "start"}}}, + {method: "session/resume", params: {sessionId, cwd, replayFrom: {type: "start"}}}, + ]); + h.emit(onWorker(agentMessageDelta("Across batch", "child-turn", "batch-output"))); + await flush(h); + expect(childUpdates().filter(entry => entry.update.sessionUpdate === "agent_message_chunk" + && entry.update.messageId === "batch-output")).toHaveLength(1); + }); + + it("does not let a queued attachment reopen a parent closed during replay", async () => { + await connect(); + history(h); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let reading = false; + h.setCodexResponse("thread/read", async params => { + const {threadId, includeTurns} = params as {threadId: string; includeTurns?: boolean}; + if (includeTurns) { reading = true; await held; } + return {thread: {...createThread(), id: threadId, turns: []}}; + }); + const first = load(); + void first.catch(() => {}); + try { + await vi.waitFor(() => expect(reading).toBe(true)); + const second = load(); + void second.catch(() => {}); + await h.request("session/close", {sessionId}); + release(); + await expect(first).rejects.toMatchObject({code: -32600}); + await expect(second).rejects.toMatchObject({code: -32600}); + } finally { release(); } + }); + + it.skipIf(version !== 2)("holds current snapshots until the complete response batch is written", async () => { + await connect(); + h.emit(itemStarted(activityItem())); + h.emit(onWorker(turnStarted("child-turn"))); + await flush(h); + history(h); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const list = vi.spyOn(h.agent, "listSessions").mockImplementationOnce(async () => { await held; return {sessions: []}; }); + const resume = vi.spyOn(h.agent, "resumeSessionV2"); + const start = h.wireMessages.length; + const before = childUpdates().length; + const batch = h.requestBatch([ + {method: "session/resume", params: {sessionId, cwd, replayFrom: {type: "start"}}}, + {method: "session/list", params: {}}, + ]); + void batch.catch(() => {}); + try { + await vi.waitFor(() => expect(resume.mock.results).toHaveLength(1)); + await resume.mock.results[0]!.value; + expect(list).toHaveBeenCalled(); + await flush(h); + expect(h.wireMessages.slice(start).some(Array.isArray)).toBe(false); + expect(childUpdates().slice(before).some(entry => entry.update.sessionUpdate === "state_update")).toBe(false); + } finally { release(); } + await batch; + await flush(h); + const frames = h.wireMessages.slice(start); + const response = frames.findIndex(Array.isArray); + const state = frames.findIndex(frame => !Array.isArray(frame) && "method" in frame && frame.method === "session/update" + && (frame.params as {sessionId: string; update: {sessionUpdate: string}}).sessionId === childId + && (frame.params as {update: {sessionUpdate: string}}).update.sessionUpdate === "state_update"); + expect(response).toBeGreaterThanOrEqual(0); + expect(state).toBeGreaterThan(response); + }); +}); diff --git a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts index 4c6b970f..6e314fbb 100644 --- a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts +++ b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts @@ -85,6 +85,8 @@ export function codexResponse(method: string): unknown { return {config: {}, origins: {}, layers: []}; case "thread/goal/get": return {goal: null}; + case "thread/list": + return {data: [], nextCursor: null}; default: return {}; } @@ -103,6 +105,9 @@ export type TranscriptEntry = | {elicitationComplete: acpV2.CompleteElicitationNotification}; export type PromptSession = { + wireMessages: acpV2.AnyWireMessage[]; + requestBatch(requests: Array<{method: string; params: unknown}>): Promise; + scopedUpdates: Array<{sessionId: string; update: acpV2.SessionUpdate | acp.SessionUpdate}>; connection: {close(): void}; transcript: TranscriptEntry[]; /** Updates published while the session was being set up, before `transcript` starts recording. */ @@ -131,6 +136,7 @@ export type PromptSession = { turnInterruptCalls(): Array<{threadId: string, turnId: string}>; /** The agent instance behind the connection, for spying on private methods (e.g. `restartCodexClient`). */ agent: CodexAcpServer; + waitForNotifications(): Promise; }; /** Connects a client (v2 by default) to the agent through the router, over a mocked Codex app-server, and opens a session. */ @@ -139,6 +145,7 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { mcpStartup?: McpStartupResult, /** Sent on `initialize` (v2 only). */ clientCapabilities?: acpV2.ClientCapabilities, + v1ClientCapabilities?: acp.ClientCapabilities, /** The Codex process exit code the agent sees (`null` = still running). */ exitCode?: () => number | null, /** Answers `session/request_permission` (v2 only). Required by tests that trigger one. */ @@ -154,6 +161,8 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { } = {}): Promise { const mocks = createMockConnections(); const transcript: TranscriptEntry[] = []; + const wireMessages: acpV2.AnyWireMessage[] = []; + const scopedUpdates: PromptSession["scopedUpdates"] = []; const turnStartParams: Array> = []; const codexResponseOverrides = new Map Promise>( Object.entries(options.codexResponses ?? {}), @@ -196,23 +205,29 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { let recordUpdates = false; const setupUpdates: acpV2.SessionUpdate[] = []; - const onUpdate = (update: acpV2.SessionUpdate | acp.SessionUpdate) => { + const onUpdate = (update: acpV2.SessionUpdate | acp.SessionUpdate, scope: string) => { if (protocolVersion === 2) { checkV2SessionUpdate(update as acpV2.SessionUpdate); } if (recordUpdates) { + scopedUpdates.push({sessionId: scope, update}); transcript.push({sessionUpdate: update as acpV2.SessionUpdate}); } else { setupUpdates.push(update as acpV2.SessionUpdate); } }; - const clientStream = acp.ndJsonStream(clientToAgent.writable, agentToClient.readable); + const stream = acpV2.ndJsonStream(clientToAgent.writable, agentToClient.readable); + const clientStream = {writable: stream.writable, + readable: stream.readable.pipeThrough(new TransformStream({ + transform(message, controller) { wireMessages.push(message); controller.enqueue(message); }, + }))}; let connection: {close(): void}; let request: (method: string, params: unknown, signal?: AbortSignal) => Promise; let notifyCancel: () => Promise; + let requestBatch: PromptSession["requestBatch"] = async () => { throw new Error("Batches require ACP v2"); }; if (protocolVersion === 2) { const v2Connection = acpV2.client({name: "test-client"}) - .onNotification(acpV2.methods.client.session.update, (ctx) => onUpdate(ctx.params.update)) + .onNotification(acpV2.methods.client.session.update, (ctx) => onUpdate(ctx.params.update, ctx.params.sessionId)) .onRequest(acpV2.methods.client.session.requestPermission, async (ctx) => { transcript.push({permissionRequest: ctx.params}); if (!options.onRequestPermission) { @@ -238,6 +253,13 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { }); await v2Connection.agent.request(acpV2.methods.agent.session.new, {cwd, ...(options.mcpServers ? {mcpServers: options.mcpServers} : {})}); connection = v2Connection; + requestBatch = async requests => { + const [first, ...rest] = requests.map(({method, params}) => acpV2.batchRequest( + method as typeof acpV2.methods.agent.session.resume, params as acpV2.ResumeSessionRequest, + )); + if (!first) throw new Error("Empty request batch"); + return v2Connection.agent.batch([first, ...rest]); + }; request = (method, params, signal) => v2Connection.agent.request( method as typeof acpV2.methods.agent.session.prompt, params as acpV2.PromptRequest, @@ -246,9 +268,25 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { notifyCancel = () => v2Connection.agent.notify(acpV2.methods.agent.session.cancel, {sessionId}); } else { const v1Connection = acp.client({name: "test-client"}) - .onNotification(acp.methods.client.session.update, (ctx) => onUpdate(ctx.params.update)) - .connect(clientStream); - await v1Connection.agent.request(acp.methods.agent.initialize, {protocolVersion: acp.PROTOCOL_VERSION}); + .onNotification(acp.methods.client.session.update, (ctx) => onUpdate(ctx.params.update, ctx.params.sessionId)) + .onRequest(acp.methods.client.session.requestPermission, async ctx => { + const request = ctx.params as unknown as acpV2.RequestPermissionRequest; + transcript.push({permissionRequest: request}); + if (!options.onRequestPermission) throw new Error("No permission handler configured"); + return await options.onRequestPermission(request, ctx.signal) as acp.RequestPermissionResponse; + }) + .onRequest(acp.methods.client.elicitation.create, async ctx => { + const request = ctx.params as unknown as acpV2.CreateElicitationRequest; + transcript.push({elicitationRequest: request}); + if (!options.onElicitation) throw new Error("No elicitation handler configured"); + return await options.onElicitation(request, ctx.signal) as acp.CreateElicitationResponse; + }) + .onNotification(acp.methods.client.elicitation.complete, ctx => { transcript.push({elicitationComplete: ctx.params}); }) + .connect({...clientStream, readable: clientStream.readable as ReadableStream}); + await v1Connection.agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + ...(options.v1ClientCapabilities ? {clientCapabilities: options.v1ClientCapabilities} : {}), + }); await v1Connection.agent.request(acp.methods.agent.session.new, {cwd, mcpServers: []}); connection = v1Connection; request = (method, params, signal) => v1Connection.agent.request( @@ -287,6 +325,9 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { return { connection, + wireMessages, + scopedUpdates, + requestBatch, transcript, setupUpdates, turnStartParams, @@ -316,6 +357,7 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { .filter(([method]: [string]) => method === "turn/interrupt") .map(([, params]: [string, {threadId: string, turnId: string}]) => params), agent: agent!, + waitForNotifications: () => codexAcpClient.waitForSessionNotifications(sessionId), }; } @@ -366,7 +408,7 @@ export function userMessageItem(clientId: string | null, text = "Hello"): Thread } /** A long-running command (no single known action, so it uses a terminal) */ -export function commandExecutionItem(id: string, status: "inProgress" | "completed" | "failed" = "inProgress"): ThreadItem { +export function commandExecutionItem(id: string, status: "inProgress" | "completed" | "failed" = "inProgress"): Extract { return { type: "commandExecution", id, diff --git a/src/__tests__/integration/standard-subagent-live.test.ts b/src/__tests__/integration/standard-subagent-live.test.ts new file mode 100644 index 00000000..9dc3e324 --- /dev/null +++ b/src/__tests__/integration/standard-subagent-live.test.ts @@ -0,0 +1,165 @@ +import * as acp from "@agentclientprotocol/sdk/unstable"; +import * as acpV2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {appendFileSync, mkdirSync, mkdtempSync, writeFileSync} from "node:fs"; +import {join} from "node:path"; +import {Readable, Writable} from "node:stream"; +import {expect, it, vi} from "vitest"; +import {AgentMode} from "../../AgentMode"; +import {removeDirectoryWithRetry, writeCodexHomeConfig} from "../acp-test-utils"; +import {describeIntegration, INTEGRATION_MODEL_ID} from "./integration-fixture"; +import {functionCallResponse, startMockResponsesServer, textResponse} from "./mock-responses-server"; + +describeIntegration("integration: live standard subagents", () => { + it.each(([1, 2] as const).flatMap(version => + (["complete", "cancel", "close", "terminal"] as const).map(ending => ({version, ending})), + ))("keeps live child delivery and ownership through ACP v$version reattachment ($ending)", async ({version, ending}) => { + const mock = await startMockResponsesServer(); + let delegated = false; + let childRequested = false; + let releaseChild!: () => void; + const held = new Promise(resolve => { releaseChild = resolve; }); + mock.setResponseHandler(async request => { + const metadata = (request as {client_metadata?: Record}).client_metadata?.["x-codex-turn-metadata"]; + const name = metadata ? (JSON.parse(metadata) as {agent_name?: string}).agent_name : undefined; + if (!childRequested && name === "/root/worker") { + childRequested = true; + await held; + if (ending === "terminal") { + const script = "console.log('STREAM_BEFORE');const t=setInterval(()=>{if(require('fs').existsSync('release-child')){console.log('STREAM_AFTER');clearInterval(t)}},25)"; + return functionCallResponse("worker-command", "exec_command", { + cmd: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`, yield_time_ms: 30_000, + }); + } + return textResponse("CHILD_RESULT"); + } + if ((request as {model?: string}).model === INTEGRATION_MODEL_ID.model && !delegated) { + delegated = true; + return functionCallResponse("delegate-once", "spawn_agent", { + message: "Reply with CHILD_RESULT", task_name: "worker", fork_turns: "none", + }, "collaboration"); + } + return textResponse("CHILD_RESULT"); + }); + const temporaryRoot = join(process.cwd(), "node_modules", ".cache", "codex-acp-tests"); + mkdirSync(temporaryRoot, {recursive: true}); + const root = mkdtempSync(join(temporaryRoot, "standard-live-")); + const cwd = join(root, "workspace"); + const codexHome = join(root, "codex-home"); + mkdirSync(cwd); + mkdirSync(codexHome); + writeCodexHomeConfig(codexHome, { + model: INTEGRATION_MODEL_ID.model, model_reasoning_effort: INTEGRATION_MODEL_ID.effort, + web_search: "disabled", model_provider: "integration", + "model_providers.integration.name": "Local integration mock", + "model_providers.integration.base_url": mock.baseUrl, "model_providers.integration.wire_api": "responses", + }); + appendFileSync(join(codexHome, "config.toml"), "\nfeatures.multi_agent = true\nfeatures.multi_agent_v2 = true\n"); + const processHandle = spawn(process.execPath, ["--import", "tsx", "src/index.ts"], { + cwd: process.cwd(), env: {...process.env, CODEX_HOME: codexHome, + // Only the fixed local fixture script bypasses host sandbox restrictions. + ...(ending === "terminal" ? {INITIAL_AGENT_MODE: AgentMode.AgentFullAccess.id} : {})}, + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + processHandle.stderr.on("data", chunk => { stderr += chunk.toString(); }); + const updates: Array<{sessionId: string; update: any}> = []; + const stream = acp.ndJsonStream(Writable.toWeb(processHandle.stdin), Readable.toWeb(processHandle.stdout) as ReadableStream); + const connection = version === 1 + ? acp.client().onNotification(acp.methods.client.session.update, ctx => { updates.push(ctx.params); }).connect(stream) + : acpV2.client().onNotification(acpV2.methods.client.session.update, ctx => { updates.push(ctx.params); }).connect(stream); + const context = connection.agent as { + request(method: string, params: unknown, options: {cancellationSignal: AbortSignal}): Promise; + notify(method: string, params: unknown): Promise; + }; + const request = async (method: string, params: unknown) => { + try { return await context.request(method, params, {cancellationSignal: AbortSignal.timeout(15_000)}); } + catch (error) { if (error instanceof Error) error.message = `${method}: ${error.message}\n${stderr}`; throw error; } + }; + try { + await request("initialize", version === 1 + ? {protocolVersion: 1, clientCapabilities: {subagents: {}, auth: {_meta: {gateway: true}}}} + : {protocolVersion: 2, info: {name: "standard-live-test", version: "1"}, capabilities: {auth: {_meta: {gateway: true}}}}); + await request(version === 1 ? "authenticate" : "auth/login", { + methodId: "gateway", _meta: {gateway: {baseUrl: mock.baseUrl, headers: {}}}, + }); + const {sessionId} = await request("session/new", {cwd, mcpServers: []}); + const prompt = request("session/prompt", {sessionId, prompt: [{type: "text", text: "Delegate once"}]}); + void prompt.catch(() => {}); + await vi.waitFor(() => { + expect(childRequested, stderr).toBe(true); + expect(updates.some(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "subagent_update")).toBe(true); + }, {timeout: 15_000}); + const childId = updates.find(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "subagent_update")!.update.sessionId; + const state = (entry: typeof updates[number]) => version === 1 + ? entry.update.sessionUpdate === "subagent_update" && entry.update.sessionId === childId ? entry.update.state : undefined + : entry.sessionId === childId && entry.update.sessionUpdate === "state_update" ? entry.update : undefined; + const configuration = (entry: typeof updates[number]) => + entry.sessionId === childId && entry.update.sessionUpdate === "config_option_update"; + await vi.waitFor(() => expect(updates.some(entry => state(entry)?.state === "running")).toBe(true)); + await vi.waitFor(() => expect(updates.some(configuration)).toBe(true)); + await prompt; + const load = (id: string) => request(version === 1 ? "session/load" : "session/resume", { + sessionId: id, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + const before = updates.length; + await load(sessionId); + await vi.waitFor(() => expect(updates.slice(before).some(entry => state(entry)?.state === "running")).toBe(true)); + await vi.waitFor(() => expect(updates.slice(before).some(configuration)).toBe(true)); + if (ending === "complete") { + const fork = await request("session/fork", {sessionId, cwd, mcpServers: []}); + await load(fork.sessionId); + const copied = updates.filter(entry => entry.sessionId === fork.sessionId && entry.update.sessionUpdate === "subagent_update"); + expect(copied.length).toBeGreaterThan(0); + expect(copied.every(entry => entry.update.sessionId !== childId)).toBe(true); + await request("session/close", {sessionId: fork.sessionId}); + } else if (ending === "terminal") { + releaseChild(); + await vi.waitFor(() => expect(updates.some(entry => entry.sessionId === childId && (version === 2 + ? entry.update.sessionUpdate === "terminal_update" && typeof entry.update.command === "string" + : entry.update._meta?.terminal_info)), stderr).toBe(true), {timeout: 15_000}); + await request("session/prompt", {sessionId, prompt: [{type: "text", text: "Continue independently"}]}); + const terminalStart = updates.length; + writeFileSync(join(cwd, "release-child"), ""); + await vi.waitFor(() => { + const newer = updates.slice(terminalStart).filter(entry => entry.sessionId === childId); + expect(newer.some(entry => version === 2 + ? entry.update.sessionUpdate === "terminal_output_chunk" && Buffer.from(entry.update.data, "base64").toString().includes("STREAM_AFTER") + : entry.update._meta?.terminal_output?.data?.includes("STREAM_AFTER") + || entry.update._meta?.terminal_output_delta?.data?.includes("STREAM_AFTER"))).toBe(true); + expect(newer.some(entry => version === 2 + ? entry.update.sessionUpdate === "terminal_update" && entry.update.exitStatus?.exitCode === 0 + : entry.update._meta?.terminal_exit?.exit_code === 0)).toBe(true); + }, {timeout: 15_000}); + } else { + const boundary = updates.length; + if (ending === "cancel") await context.notify("session/cancel", {sessionId}); + else await request("session/close", {sessionId}); + await vi.waitFor(() => expect(updates.slice(boundary).some(entry => { + const current = state(entry); + return ending === "close" ? current?.state === "unknown" + : current?.state === "idle" && current.stopReason === "cancelled"; + }), stderr).toBe(true), {timeout: 15_000}); + } + releaseChild(); + if (ending === "complete" || ending === "terminal") await vi.waitFor(() => { + const announcement = updates.findIndex(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "subagent_update"); + const message = updates.findIndex(entry => entry.sessionId === childId + && entry.update.sessionUpdate === "agent_message_chunk" && entry.update.content.text.includes("CHILD_RESULT")); + expect(message, stderr).toBeGreaterThan(announcement); + expect(updates.some(entry => state(entry)?.state === "idle")).toBe(true); + }, {timeout: 15_000}); + expect(updates.some(entry => ["subagent_spawned", "subagent_state_update", "_subagent_update"].includes(entry.update.sessionUpdate))).toBe(false); + expect(mock.unexpectedRequests).toBe(0); + } finally { + writeFileSync(join(cwd, "release-child"), ""); + releaseChild(); + connection.close(); + processHandle.kill("SIGTERM"); + if (processHandle.exitCode === null) await once(processHandle, "exit"); + await mock.close(); + await removeDirectoryWithRetry(root); + } + }); +}); diff --git a/src/permissions/CodexApprovalHandler.ts b/src/permissions/CodexApprovalHandler.ts index a5e1af4b..a124d5f8 100644 --- a/src/permissions/CodexApprovalHandler.ts +++ b/src/permissions/CodexApprovalHandler.ts @@ -29,6 +29,7 @@ import { } from "./metadata"; import type {PermissionPromptContext} from "./lifecycle"; import {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; +import {subagentInteractionSignal, withSubagentUserAction} from "../subagents/SubagentInteraction"; import {CommandReporter} from "../tool-calls/reporters/CommandReporter"; import {FileChangeReporter} from "../tool-calls/reporters/FileChangeReporter"; import {SandboxPermissionReporter} from "../tool-calls/reporters/SandboxPermissionReporter"; @@ -123,11 +124,12 @@ export class CodexApprovalHandler implements ApprovalHandler { } private requestPermission(request: acp.RequestPermissionRequest): Promise { - return this.connection.request( + const signal = subagentInteractionSignal(request.sessionId, this.cancellationSignal); + return withSubagentUserAction(request.sessionId, () => this.connection.request( acp.methods.client.session.requestPermission, request, - this.cancellationSignal ? {cancellationSignal: this.cancellationSignal} : undefined, - ); + signal ? {cancellationSignal: signal} : undefined, + ), request.toolCall.toolCallId); } private selectedDecision(response: acp.RequestPermissionResponse, decisions: DecisionOption[]): T | undefined { diff --git a/src/subagents/AcpSubagents.ts b/src/subagents/AcpSubagents.ts index b0c27a7c..2c195a53 100644 --- a/src/subagents/AcpSubagents.ts +++ b/src/subagents/AcpSubagents.ts @@ -5,9 +5,11 @@ import type { import { AIR_NATIVE_SUBAGENT_SESSIONS_KEY, clientSupportsAirCapability, + isAirClient, } from "../AirExtension"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; -/** Temporary typed surface for agentclientprotocol/agent-client-protocol#1992. */ +/** AIR's private contract, retained independently of standard subagent sessions. */ export type SubagentSessionCapabilities = { cancel?: boolean; close?: boolean; @@ -36,15 +38,22 @@ export type SubagentAwareSessionCapabilities = SessionCapabilities & { subagents?: Record; }; -export function clientSupportsSubagents( +export type StandardSubagentUpdate = Omit & { + sessionUpdate: "subagent_update"; + capabilities?: Record; +}; + +export function subagentProtocol( capabilities?: ClientCapabilities | null, -): boolean { + version: 1 | 2 = 1, +): "hidden" | "air" | "standard" { const subagents = ( capabilities as (ClientCapabilities & { subagents?: unknown }) | null | undefined )?.subagents; - if (typeof subagents === "object" && subagents !== null && !Array.isArray(subagents)) { - return true; - } - - return clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_KEY); + const standard = typeof subagents === "object" && subagents !== null && !Array.isArray(subagents); + const air = isAirClient(capabilities); + if (clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_KEY) + || (version === 1 && air && standard)) return "air"; + if (air) return "hidden"; + return version === 2 || standard ? "standard" : "hidden"; } diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index 3e1a7d98..8b1098f5 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -45,15 +45,15 @@ type EndedSpawn = { */ const MODEL_SOURCE_RANK = {threadStarted: 0, activity: 1, settings: 2} as const; -type ChildModel = { +export type ChildModel = { model: string; source: keyof typeof MODEL_SOURCE_RANK; }; /** A report of a lower rank does not replace the model, so a `thread/started` of a resume keeps the activity model. */ -function preferredModel(current: ChildModel | undefined, next: ChildModel): ChildModel; -function preferredModel(current: ChildModel | undefined, next: ChildModel | undefined): ChildModel | undefined; -function preferredModel(current: ChildModel | undefined, next: ChildModel | undefined): ChildModel | undefined { +export function preferredModel(current: ChildModel | undefined, next: ChildModel): ChildModel; +export function preferredModel(current: ChildModel | undefined, next: ChildModel | undefined): ChildModel | undefined; +export function preferredModel(current: ChildModel | undefined, next: ChildModel | undefined): ChildModel | undefined { if (!current || !next) return current ?? next; return MODEL_SOURCE_RANK[next.source] >= MODEL_SOURCE_RANK[current.source] ? next : current; } diff --git a/src/subagents/CodexSubagentSubscriptions.ts b/src/subagents/CodexSubagentSubscriptions.ts index 27998e08..c2f9a913 100644 --- a/src/subagents/CodexSubagentSubscriptions.ts +++ b/src/subagents/CodexSubagentSubscriptions.ts @@ -4,8 +4,13 @@ import type { ElicitationHandler, } from "../CodexAppServerClient"; import type {ServerNotification} from "../app-server"; +import type {Thread} from "../app-server/v2"; import {isRootAgentPath} from "./CodexAgentPath"; +export type SubagentInteraction = ( + sessionId: string, itemId: string | undefined, request: () => Promise, turnId?: string | null, +) => Promise; + type Subscription = { rootSessionId: string; supportsSubagents: boolean; @@ -15,6 +20,8 @@ type Subscription = { elicitationHandler: ElicitationHandler; waitForRootNotifications(): Promise; waitForChildSession(childThreadId: string): Promise; + withUserAction?: SubagentInteraction; + observeWorkEnd?: (event: ServerNotification) => void; }; type SessionSubscription = { @@ -41,6 +48,7 @@ export class CodexSubagentSubscriptions { // Register synchronously: app-server may emit child output directly // after the spawning collaboration item. this.discover(session, event); + session.current.observeWorkEnd?.(event); session.current.dispatch(event); }); this.registerInteractiveHandlers(session, subscription.rootSessionId); @@ -53,6 +61,33 @@ export class CodexSubagentSubscriptions { this.sessions.delete(rootSessionId); } + redispatch(rootSessionId: string, notifications: ServerNotification[]): void { + const session = this.sessions.get(rootSessionId); + if (session) for (const notification of notifications) session.current.dispatch(notification); + } + + isChildThread(threadId: string): boolean { + return [...this.sessions.values()].some(session => session.children.has(threadId)); + } + + async restoreChild(rootSessionId: string, parentThreadId: string, thread: Thread): Promise { + const session = this.sessions.get(rootSessionId); + if (!session || session.children.has(thread.id) || thread.parentThreadId !== parentThreadId + || (parentThreadId !== rootSessionId && !session.children.has(parentThreadId))) return null; + if (!this.attachChild(session, thread.id)) return null; + if (thread.status.type === "notLoaded") return null; + try { + const response = await this.client.threadResume({threadId: thread.id, excludeTurns: true}); + if (this.sessions.get(rootSessionId) === session && response.thread.id === thread.id + && response.thread.parentThreadId === parentThreadId && response.thread.status.type !== "notLoaded") return response.thread; + } catch (error) { + this.detachChild(session, thread.id); + throw error; + } + this.detachChild(session, thread.id); + return null; + } + private discover(session: SessionSubscription, event: ServerNotification): void { if (event.method !== "item/started" && event.method !== "item/completed") { return; @@ -70,19 +105,30 @@ export class CodexSubagentSubscriptions { || session.children.has(childSessionId)) { continue; } - session.children.add(childSessionId); - this.client.onServerNotification(childSessionId, (childEvent) => { - const eventThreadId = (childEvent.params as {threadId?: unknown}).threadId; - if (eventThreadId !== childSessionId) return; - this.discover(session, childEvent); - if (session.current.supportsSubagents) session.current.dispatch(childEvent); - else session.current.enqueueInteraction(this.rootAttributed(childEvent, session.current.rootSessionId)); - }); - // Hidden children keep only root-attributed permission requests. - this.registerInteractiveHandlers(session, childSessionId); + this.attachChild(session, childSessionId); } } + private attachChild(session: SessionSubscription, childSessionId: string): boolean { + if ([...this.sessions.values()].some(other => other !== session + && (other.current.rootSessionId === childSessionId || other.children.has(childSessionId)))) return false; + session.children.add(childSessionId); + this.client.onServerNotification(childSessionId, event => { + if ((event.params as {threadId?: unknown}).threadId !== childSessionId) return; + this.discover(session, event); + session.current.observeWorkEnd?.(event); + if (session.current.supportsSubagents) session.current.dispatch(event); + else session.current.enqueueInteraction(this.rootAttributed(event, session.current.rootSessionId)); + }); + this.registerInteractiveHandlers(session, childSessionId); + return true; + } + + private detachChild(session: SessionSubscription, childSessionId: string): void { + if (this.sessions.get(session.current.rootSessionId) !== session || !session.children.delete(childSessionId)) return; + if (!this.sessions.has(childSessionId)) this.client.clearThreadHandlers(childSessionId); + } + private registerInteractiveHandlers(session: SessionSubscription, targetSessionId: string): void { this.client.onApprovalRequest(targetSessionId, { handleCommandExecution: async (params) => { @@ -90,27 +136,24 @@ export class CodexSubagentSubscriptions { await current.waitForRootNotifications(); const sessionId = await this.interactionSessionId(current, targetSessionId); if (sessionId === null) return {decision: "cancel"}; - return await current.approvalHandler.handleCommandExecution( - {...params, threadId: sessionId}, - ); + const request = () => current.approvalHandler.handleCommandExecution({...params, threadId: sessionId}); + return current.withUserAction ? current.withUserAction(sessionId, params.itemId, request, params.turnId) : request(); }, handleFileChange: async (params) => { const current = session.current; await current.waitForRootNotifications(); const sessionId = await this.interactionSessionId(current, targetSessionId); if (sessionId === null) return {decision: "cancel"}; - return await current.approvalHandler.handleFileChange( - {...params, threadId: sessionId}, - ); + const request = () => current.approvalHandler.handleFileChange({...params, threadId: sessionId}); + return current.withUserAction ? current.withUserAction(sessionId, params.itemId, request, params.turnId) : request(); }, handlePermissionsRequest: async (params) => { const current = session.current; await current.waitForRootNotifications(); const sessionId = await this.interactionSessionId(current, targetSessionId); if (sessionId === null) return {permissions: {}, scope: "turn", strictAutoReview: false}; - return await current.approvalHandler.handlePermissionsRequest( - {...params, threadId: sessionId}, - ); + const request = () => current.approvalHandler.handlePermissionsRequest({...params, threadId: sessionId}); + return current.withUserAction ? current.withUserAction(sessionId, params.itemId, request, params.turnId) : request(); }, }); this.client.onElicitationRequest(targetSessionId, { @@ -119,18 +162,16 @@ export class CodexSubagentSubscriptions { await current.waitForRootNotifications(); const sessionId = await this.interactionSessionId(current, targetSessionId); if (sessionId === null) return {action: "cancel", content: null, _meta: null}; - return await current.elicitationHandler.handleElicitation( - {...params, threadId: sessionId}, - ); + const request = () => current.elicitationHandler.handleElicitation({...params, threadId: sessionId}); + return current.withUserAction ? current.withUserAction(sessionId, undefined, request, params.turnId) : request(); }, handleUserInput: async (params) => { const current = session.current; await current.waitForRootNotifications(); const sessionId = await this.interactionSessionId(current, targetSessionId); if (sessionId === null) return {answers: {}}; - return await current.elicitationHandler.handleUserInput( - {...params, threadId: sessionId}, - ); + const request = () => current.elicitationHandler.handleUserInput({...params, threadId: sessionId}); + return current.withUserAction ? current.withUserAction(sessionId, params.itemId, request, params.turnId) : request(); }, }); } diff --git a/src/subagents/StandardSubagentEventRouter.ts b/src/subagents/StandardSubagentEventRouter.ts new file mode 100644 index 00000000..90692ac6 --- /dev/null +++ b/src/subagents/StandardSubagentEventRouter.ts @@ -0,0 +1,529 @@ +import type {ServerNotification} from "../app-server"; +import type {StateUpdate} from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type {Thread, ThreadItem} from "../app-server/v2"; +import {ACPSessionConnection} from "../ACPSessionConnection"; +import {AcpV2ForegroundState} from "../AcpV2ForegroundState"; +import {CodexPlanStream} from "../CodexPlanStream"; +import {CodexSessionToolCalls} from "../CodexSessionToolCalls"; +import {CodexItemStreamState} from "../CodexItemStreamState"; +import {ClientCapabilities} from "../tool-calls/ClientCapabilities"; +import {readableServiceErrorMessage} from "../ServiceErrorMessage"; +import {toV2IdleState} from "../AcpV2Prompt"; +import {normalizeSessionTitle} from "../SessionTitle"; +import {CollabAgentReporter} from "../tool-calls/reporters/CollabAgentReporter"; +import {SubagentActivityReporter} from "../tool-calls/reporters/SubagentActivityReporter"; +import type {SubagentState} from "./AcpSubagents"; +import { + preferredModel, reportSubagentModel, type ChildModel, type ClosingChildSession, type SubagentModels, +} from "./CodexSubagentEventRouter"; +import {isRootAgentPath, nameFromAgentPath, normalizeAgentPath} from "./CodexAgentPath"; +import {PendingNotificationBuffer} from "./PendingNotificationBuffer"; +import {SubagentAssociations, type SubagentAssociation} from "./SubagentAssociations"; +import type {SubagentEventRouter} from "./SubagentEventRouter"; +import {runSubagentInteraction} from "./SubagentInteraction"; +import {logger} from "../Logger"; + +type Child = SubagentAssociation & { + path?: string; + title?: string | null; + description?: string; + turnId: string | null; + foregroundTurnId: string | null; + foreground: AcpV2ForegroundState; + toolCalls: CodexSessionToolCalls; + streams: CodexItemStreamState; + requests: AbortController; + workRequests: Map; + closedRequestTurns: Set; + endedTurns: Set; + live: boolean; +}; +type Pending = { + parentThreadId?: string; + path?: string; + description?: string; + notifications: PendingNotificationBuffer; +}; + +/** Work ends; the reusable child conversation and its restrictions remain. */ +export class StandardSubagentEventRouter implements SubagentEventRouter { + private readonly children = new Map(); + private readonly pending = new Map(); + private readonly replayQueue: ServerNotification[] = []; + private readonly deferredLive: PendingNotificationBuffer; + private readonly legacyActivities = new Set(); + private readonly announced = new Set(); + private readonly models = new Map(); + private readonly pendingModels = new Set(); + private readonly spawnsInFlight = new Set(); + private readonly appliedSettings = new WeakSet(); + private retired = false; + private snapshotting = false; + private readonly snapshots = new Map(); + private interactionObserver?: (event: ServerNotification, sessionId: string) => Promise; + + constructor( + private readonly rootSessionId: string, + private readonly session: ACPSessionConnection, + private readonly associations: SubagentAssociations, + private readonly onChildWorkEnded: (sessionId: string) => void, + private readonly capabilities = ClientCapabilities.DEFAULT, + private deferLive = false, + private readonly ready: Promise = Promise.resolve(), + private readonly runningTurnId: (threadId: string) => string | undefined = () => undefined, + private readonly rootModels: () => SubagentModels | null = () => null, + ) { + this.deferredLive = new PendingNotificationBuffer(rootSessionId); + } + + /** Admission precedes rendering, including terminal/tool cleanup. */ + async prepareNotification(notification: ServerNotification): Promise { + const threadId = (notification.params as {threadId?: unknown}).threadId; + if (typeof threadId !== "string" || threadId === this.rootSessionId) return true; + if (this.retired) return false; + if (notification.method === "thread/settings/updated" && !this.appliedSettings.has(notification.params)) { + this.appliedSettings.add(notification.params); + await this.observeModel(threadId, notification.params.threadSettings.model, "settings"); + } + if (this.deferLive) { this.deferredLive.push(notification); return false; } + const child = this.children.get(threadId); + if (!child) { + const pending = this.pendingFor(threadId); + pending.notifications.push(notification); + if (await this.restoreKnown(threadId)) { + this.pending.delete(threadId); + this.replayQueue.push(...pending.notifications.take()); + } + return false; + } + await this.ensureAnnounced(child); + return !this.retired; + } + + async handle(notification: ServerNotification): Promise { + if (notification.method === "thread/started") { + const thread = notification.params.thread; + if (thread.parentThreadId === this.rootSessionId || this.children.has(thread.parentThreadId ?? "") + || this.children.has(thread.id) || this.spawnsInFlight.size > 0) { + await this.observeModel(thread.id, thread.model, "threadStarted"); + } + return false; + } + const threadId = (notification.params as {threadId?: unknown}).threadId; + if (typeof threadId !== "string") return false; + if (this.retired) return threadId !== this.rootSessionId + || ((notification.method === "item/started" || notification.method === "item/completed") + && (notification.params.item.type === "subAgentActivity" || notification.params.item.type === "collabAgentToolCall")); + if (notification.method === "item/started" || notification.method === "item/completed") { + const item = notification.params.item; + if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") { + if (notification.method === "item/started") this.spawnsInFlight.add(item.id); + else this.spawnsInFlight.delete(item.id); + for (const id of item.receiverThreadIds) { + if (!id.trim() || id === item.senderThreadId || id === this.rootSessionId || this.children.has(id)) continue; + const pending = this.pendingFor(id); + pending.parentThreadId = item.senderThreadId; + if (item.prompt) pending.description = item.prompt; + } + await this.materializePending(); + return true; + } + if (item.type === "subAgentActivity") { + if (isRootAgentPath(item.agentPath)) return true; + const child = this.children.get(item.agentThreadId); + if (child) { + await this.ensureAnnounced(child); + child.path = normalizeAgentPath(item.agentPath); + const title = nameFromAgentPath(item.agentPath, child.title ?? "Agent"); + if (title !== child.title) { + child.title = title; + await this.session.update({sessionUpdate: "subagent_update", sessionId: child.sessionId, title}, child.parentSessionId); + } + } else this.pendingFor(item.agentThreadId).path = normalizeAgentPath(item.agentPath); + await this.observeModel(item.agentThreadId, item.model, "activity"); + await this.materializePending(); + return true; + } + } + if (threadId === this.rootSessionId && notification.method === "turn/completed") { + this.spawnsInFlight.clear(); + for (const id of this.models.keys()) if (!this.children.has(id)) this.models.delete(id); + } + const child = this.children.get(threadId); + if (!child) return false; + await this.ensureAnnounced(child); + if (this.retired) return true; + await this.interactionObserver?.(notification, child.sessionId); + child.live = true; + const progressTurnId = (notification.params as {turnId?: string}).turnId; + const progress = notification.method === "item/agentMessage/delta" || notification.method === "item/plan/delta" + || notification.method === "item/reasoning/textDelta" || notification.method === "item/reasoning/summaryTextDelta"; + if (progressTurnId !== undefined && progress && !child.endedTurns.has(progressTurnId) + && (child.turnId === null || child.turnId === progressTurnId)) { + child.turnId ??= progressTurnId; + if (this.isCurrentForeground(child, progressTurnId)) { + this.beginForegroundTurn(child, progressTurnId); + await child.foreground.start(); + } + } + switch (notification.method) { + case "turn/started": + if (child.endedTurns.has(notification.params.turn.id)) return true; + if (child.turnId !== null && child.turnId !== notification.params.turn.id) this.endWorkRequests(child, child.turnId); + child.turnId = notification.params.turn.id; + if (this.isCurrentForeground(child, child.turnId)) { + this.beginForegroundTurn(child, child.turnId); + await child.foreground.start(); + } + return true; + case "thread/settings/updated": + return true; + case "thread/name/updated": { + const title = normalizeSessionTitle(notification.params.threadName); + child.title = title; + await this.session.update({sessionUpdate: "session_info_update", title}, child.sessionId); + await this.session.update({sessionUpdate: "subagent_update", sessionId: child.sessionId, title}, child.parentSessionId); + return true; + } + case "thread/closed": + case "thread/archived": + await this.unknown(child); + return true; + case "error": + case "thread/unarchived": + case "thread/goal/updated": + case "thread/goal/cleared": + case "model/rerouted": + // Child incidents and execution settings must not mutate the parent. + return true; + case "turn/completed": { + const turn = notification.params.turn; + if (turn.status === "inProgress" || child.endedTurns.has(turn.id)) return true; + this.endWorkRequests(child, turn.id); + child.endedTurns.add(turn.id); + if (child.turnId !== null && child.turnId !== turn.id) return true; + child.turnId = null; + if (child.foregroundTurnId !== null && child.foregroundTurnId !== turn.id) return true; + await child.foreground.update(toV2IdleState( + {stopReason: turn.status === "interrupted" ? "cancelled" : "end_turn"}, + turn.status === "failed" ? {code: -32603, + message: turn.error ? readableServiceErrorMessage(turn.error.message) : "Subagent work failed"} : undefined, + )); + child.foregroundTurnId = null; + this.onChildWorkEnded(child.sessionId); + return true; + } + case "thread/status/changed": + if (notification.params.status.type === "notLoaded") await this.unknown(child); + else if (notification.params.status.type === "active" && notification.params.status.activeFlags.length === 0) { + await child.foreground.start(); + } + return true; + case "item/started": + if (notification.params.item.type === "userMessage") return true; + if (child.turnId !== null && child.foregroundTurnId === notification.params.turnId) { + await child.foreground.startItem(notification.params.item.id); + } + if (notification.params.item.type === "plan") return true; + break; + case "item/completed": { + if (child.foregroundTurnId === notification.params.turnId) await child.foreground.completeItem(notification.params.item.id); + const item = notification.params.item; + if (item.type === "userMessage") return true; + if (item.type === "plan") { + const plans = new CodexPlanStream(this.session.forSession(child.sessionId), this.capabilities); + const plan = await plans.completed(item.id, item.text); + if (plan?.update) await this.session.update(plan.update, child.sessionId); + return true; + } + break; + } + case "item/plan/delta": + return true; + } + return false; + } + + private pendingFor(threadId: string): Pending { + let pending = this.pending.get(threadId); + if (!pending) { + pending = {notifications: new PendingNotificationBuffer(threadId)}; + this.pending.set(threadId, pending); + } + return pending; + } + + private parentFor(pending: Pending): string | undefined { + if (pending.parentThreadId !== undefined) return pending.parentThreadId === this.rootSessionId + ? this.rootSessionId : this.children.get(pending.parentThreadId)?.sessionId; + if (pending.path === undefined) return undefined; + const parentPath = pending.path.slice(0, pending.path.lastIndexOf("/")); + if (isRootAgentPath(parentPath) || parentPath === this.rootSessionId || parentPath === `/${this.rootSessionId}`) return this.rootSessionId; + return [...this.children.values()].find(child => child.path === parentPath)?.sessionId; + } + + private async materializePending(): Promise { + let changed: boolean; + do { + changed = false; + for (const [threadId, pending] of this.pending) { + const parent = this.parentFor(pending); + if (parent === undefined) continue; + await this.announce(threadId, parent, pending.path, pending.description, true); + this.pending.delete(threadId); + this.replayQueue.push(...pending.notifications.take()); + changed = true; + } + } while (changed); + } + + private async restoreKnown(threadId: string): Promise { + if (this.children.has(threadId)) return true; + const stored = this.associations.forRoot(this.rootSessionId).find(child => child.threadId === threadId); + if (!stored) return false; + if (stored.parentSessionId !== this.rootSessionId) { + const parent = this.associations.get(stored.parentSessionId); + if (!parent || !await this.restoreKnown(parent.threadId)) return false; + } + await this.announce(threadId, stored.parentSessionId); + return true; + } + + private async ensureAnnounced(child: Child): Promise { + await this.ready; + if (this.retired || this.announced.has(child.threadId)) return; + const parent = [...this.children.values()].find(candidate => candidate.sessionId === child.parentSessionId); + if (parent) await this.ensureAnnounced(parent); + await this.session.update({sessionUpdate: "subagent_update", sessionId: child.sessionId, capabilities: {}, + ...(child.title !== undefined ? {title: child.title} : {}), + ...(child.description !== undefined ? {description: child.description} : {})}, child.parentSessionId); + this.announced.add(child.threadId); + if (this.models.has(child.threadId)) { + this.pendingModels.add(child.threadId); + if (!this.deferLive && !this.snapshotting) await this.publishModel(child); + } + } + + async announce(threadId: string, parentSessionId: string, path?: string, description?: string, live = false): Promise { + const existing = this.children.get(threadId); + if (existing) { existing.live ||= live; await this.ensureAnnounced(existing); return existing.sessionId; } + const association = this.associations.register(this.rootSessionId, parentSessionId, threadId); + const child: Child = { + ...association, turnId: null, foregroundTurnId: null, + ...(path !== undefined ? {path: normalizeAgentPath(path), title: nameFromAgentPath(path, "Agent")} : {}), + ...(description !== undefined ? {description} : {}), + foreground: new AcpV2ForegroundState(async state => { + if (this.retired) { + if (state.state === "unknown" && this.announced.has(threadId)) { + await this.session.updateSubagentState(association.sessionId, parentSessionId, state); + } + return; + } + if (this.deferLive || this.snapshotting) { this.snapshots.set(threadId, {child, state}); return; } + await this.session.updateSubagentState(association.sessionId, parentSessionId, state); + }, "unknown"), + toolCalls: new CodexSessionToolCalls(), streams: new CodexItemStreamState(), + requests: new AbortController(), workRequests: new Map(), closedRequestTurns: new Set(), endedTurns: new Set(), live, + }; + this.children.set(threadId, child); + await this.ensureAnnounced(child); + return child.sessionId; + } + + async announceHistory(threadId: string, path: string): Promise { + if (threadId === this.rootSessionId) return null; + if (await this.restoreKnown(threadId)) return this.children.get(threadId)!.sessionId; + const parent = this.parentFor({path: normalizeAgentPath(path), notifications: new PendingNotificationBuffer(threadId)}); + return parent === undefined ? null : this.announce(threadId, parent, path); + } + + private async observeModel(threadId: string, model: string | null, source: ChildModel["source"]): Promise { + if (!model || this.retired) return; + const previous = this.models.get(threadId); + const next = preferredModel(previous, {model, source}); + this.models.set(threadId, next); + if (next.model === previous?.model) return; + const child = this.children.get(threadId); + if (!child) return; + this.pendingModels.add(threadId); + if (!this.deferLive && !this.snapshotting) await this.publishModel(child); + } + + private async publishModel(child: Child): Promise { + this.pendingModels.delete(child.threadId); + await this.ensureAnnounced(child); + await reportSubagentModel(this.session, child.sessionId, this.rootModels(), this.models.get(child.threadId)?.model); + } + + releaseReplay(): ServerNotification[] { + this.snapshotting = this.deferLive; + this.deferLive = false; + return this.deferredLive.take(); + } + + async publishReplaySnapshots(): Promise { + try { + while (this.snapshots.size > 0 || this.pendingModels.size > 0) { + for (const {child} of [...this.snapshots.values()]) { + await child.foreground.serializePublication(async () => { + const snapshot = this.snapshots.get(child.threadId); + this.snapshots.delete(child.threadId); + if (!snapshot || this.retired) return; + await this.ensureAnnounced(child); + await this.session.updateSubagentState(child.sessionId, child.parentSessionId, snapshot.state); + }); + } + for (const id of [...this.pendingModels]) { + const child = this.children.get(id); + if (child && !this.retired) await this.publishModel(child); + else this.pendingModels.delete(id); + } + } + } finally { this.snapshotting = false; } + } + + pauseForSetup(): void { + this.deferLive = true; + this.announced.clear(); + for (const child of this.children.values()) { + void child.foreground.snapshot().catch(error => logger.error("Failed to snapshot child work", error)); + } + } + + async withUserAction(sessionId: string, itemId: string | undefined, request: () => Promise, turnId?: string | null): Promise { + const child = [...this.children.values()].find(child => child.sessionId === sessionId); + if (!child) return request(); + child.live = true; + await this.ensureAnnounced(child); + const work = typeof turnId === "string" ? child.workRequests.get(turnId) ?? {controller: new AbortController(), count: 0} : undefined; + if (work && typeof turnId === "string") { child.workRequests.set(turnId, work); work.count++; } + const obsolete = typeof turnId === "string" && (child.closedRequestTurns.has(turnId) || child.endedTurns.has(turnId)); + const signal = this.retired || obsolete ? AbortSignal.abort() : work + ? AbortSignal.any([child.requests.signal, work.controller.signal]) : child.requests.signal; + const currentTurn = this.runningTurnId(child.threadId) ?? child.turnId ?? child.foregroundTurnId; + const foreground = !this.retired && !obsolete && typeof turnId === "string" + && (currentTurn === null || currentTurn === turnId); + if (foreground) this.beginForegroundTurn(child, turnId); + try { + return await runSubagentInteraction({sessionId, signal, + nextToolCallId: server => this.associations.nextToolCallId(sessionId, server), + beforeRequest: () => this.ensureAnnounced(child), + ...(foreground ? {foreground: child.foreground} : {}), ...(itemId !== undefined ? {itemId} : {})}, request); + } finally { + if (work && --work.count === 0 && typeof turnId === "string" && child.workRequests.get(turnId) === work) child.workRequests.delete(turnId); + } + } + + private endWorkRequests(child: Child, turnId: string): void { child.workRequests.get(turnId)?.controller.abort(); child.workRequests.delete(turnId); } + private beginForegroundTurn(child: Child, turnId: string): void { + if (child.foregroundTurnId === turnId) return; + child.foregroundTurnId = turnId; + child.foreground.invalidate(); + } + private isCurrentForeground(child: Child, turnId: string): boolean { + const current = this.runningTurnId(child.threadId); + return current === undefined || current === turnId; + } + observeWorkEnd(notification: ServerNotification): void { + if (!this.retired && notification.method === "turn/completed" && notification.params.turn.status !== "inProgress") { + this.closeWorkRequests(notification.params.threadId, notification.params.turn.id); + } + } + closeWorkRequests(threadId: string, turnId: string): void { + const child = this.children.get(threadId); + if (!child) return; + child.closedRequestTurns.add(turnId); + this.endWorkRequests(child, turnId); + } + cancelInteractions(): void { + for (const child of this.children.values()) { + if (child.turnId !== null) child.closedRequestTurns.add(child.turnId); + child.requests.abort(); + child.requests = new AbortController(); + for (const [turnId, work] of child.workRequests) { child.closedRequestTurns.add(turnId); work.controller.abort(); } + child.workRequests.clear(); + } + } + private async unknown(child: Child): Promise { + this.models.delete(child.threadId); + this.pendingModels.delete(child.threadId); + child.requests.abort(); + child.requests = new AbortController(); + for (const work of child.workRequests.values()) work.controller.abort(); + child.workRequests.clear(); + await child.foreground.update({state: "unknown"}); + child.turnId = null; + child.foregroundTurnId = null; + } + async finishOutstanding(state: SubagentState): Promise { + if (state !== "disconnected") return; + this.retired = true; + this.cancelInteractions(); + for (const child of this.children.values()) { + if (this.announced.has(child.threadId)) await this.unknown(child); + else child.foreground.invalidate(); + child.turnId = null; + } + } + activeTurns(): Array<{threadId: string; turnId: string | null}> { + return [...this.children.values()].filter(child => child.live).map(child => ({threadId: child.threadId, turnId: child.turnId})); + } + setInteractionObserver(observer: (event: ServerNotification, sessionId: string) => Promise): void { this.interactionObserver = observer; } + isRetired(): boolean { return this.retired; } + openToolCalls(threadId: string): CodexSessionToolCalls | undefined { return this.children.get(threadId)?.toolCalls; } + itemStreams(threadId: string): CodexItemStreamState | undefined { return this.children.get(threadId)?.streams; } + parentThreadId(threadId: string): string | undefined { + const parentId = this.children.get(threadId)?.parentSessionId; + return parentId === this.rootSessionId ? parentId : parentId === undefined ? undefined : this.associations.get(parentId)?.threadId; + } + async restoreRuntime(thread: Thread, turnId: string | undefined): Promise { + const child = this.children.get(thread.id); + if (!child || this.retired) return; + child.live = true; + await this.observeModel(thread.id, thread.model, "threadStarted"); + if (thread.status.type === "idle") { + child.turnId = null; + child.foregroundTurnId = null; + await child.foreground.update({state: "idle"}); + } else if (thread.status.type === "active" && thread.status.activeFlags.length === 0) { + child.turnId ??= turnId ?? null; + if (child.turnId !== null) this.beginForegroundTurn(child, child.turnId); + await child.foreground.start(); + } + } + childSessionIds(): string[] { return [...this.children.values()].map(child => child.sessionId); } + isNativeSubagentThread(threadId: string): boolean { return this.children.has(threadId); } + shouldIgnore(): boolean { return false; } + notificationSessionId(notification: ServerNotification): string { + const threadId = (notification.params as {threadId?: unknown}).threadId; + return typeof threadId === "string" ? this.children.get(threadId)?.sessionId ?? this.rootSessionId : this.rootSessionId; + } + takeBufferedNotifications(): ServerNotification[] { return this.replayQueue.splice(0); } + async waitForMaterializedSession(threadId: string): Promise { + if (this.retired) return null; + await this.materializePending(); + const child = this.children.get(threadId); + if (child) await this.ensureAnnounced(child); + return child?.sessionId ?? null; + } + closingChildSessions(notification: ServerNotification): ClosingChildSession[] { + if (notification.method !== "turn/completed") return []; + const child = this.children.get(notification.params.threadId); + const turn = notification.params.turn; + if (!child || turn.status === "inProgress" || child.endedTurns.has(turn.id) + || (child.turnId !== null && child.turnId !== turn.id)) return []; + return [{threadId: child.threadId, sessionId: child.sessionId, + state: turn.status === "interrupted" ? "cancelled" : turn.status === "failed" ? "failed" : "completed"}]; + } + async wait(_signal: AbortSignal): Promise<"settled"> { return "settled"; } + hasOutstanding(): boolean { return [...this.children.values()].some(child => child.turnId !== null); } + legacyActivityStarted(item: ThreadItem & {type: "subAgentActivity"}) { + this.legacyActivities.add(item.id); + return SubagentActivityReporter.activity(item, "in_progress", "start"); + } + legacyActivityCompleted(item: ThreadItem & {type: "subAgentActivity"}) { + return SubagentActivityReporter.activity(item, "completed", this.legacyActivities.delete(item.id) ? "update" : "start"); + } + legacyCollaborationStarted(item: ThreadItem & {type: "collabAgentToolCall"}) { return CollabAgentReporter.started(item); } + legacyCollaborationCompleted(item: ThreadItem & {type: "collabAgentToolCall"}) { return CollabAgentReporter.completed(item); } +} diff --git a/src/subagents/SubagentAssociations.ts b/src/subagents/SubagentAssociations.ts new file mode 100644 index 00000000..a3561c66 --- /dev/null +++ b/src/subagents/SubagentAssociations.ts @@ -0,0 +1,56 @@ +import {RequestError} from "@agentclientprotocol/sdk/unstable"; + +export type SubagentAssociation = { + rootSessionId: string; + parentSessionId: string; + threadId: string; + sessionId: string; +}; + +/** Connection-owned identities and restrictions survive handler replacement and idle work. */ +export class SubagentAssociations { + private readonly associations = new Map(); + private readonly childThreads = new Set(); + private readonly interactionSequences = new Map(); + + sessionId(rootSessionId: string, threadId: string): string { + return `${encodeURIComponent(rootSessionId)}:subagent:${encodeURIComponent(threadId)}`; + } + + register(rootSessionId: string, parentSessionId: string, threadId: string): SubagentAssociation { + const sessionId = this.sessionId(rootSessionId, threadId); + if (threadId === rootSessionId || sessionId === parentSessionId) { + throw RequestError.internalError(undefined, "A subagent cannot own itself"); + } + if (parentSessionId !== rootSessionId + && this.associations.get(parentSessionId)?.rootSessionId !== rootSessionId) { + throw RequestError.internalError(undefined, "A subagent's immediate parent must be known first"); + } + const previous = this.associations.get(sessionId); + if (previous) { + if (previous.parentSessionId !== parentSessionId) { + throw RequestError.internalError(undefined, "A subagent association cannot change parents"); + } + return previous; + } + const association = {rootSessionId, parentSessionId, threadId, sessionId}; + this.associations.set(sessionId, association); + this.childThreads.add(threadId); + return association; + } + + isRestricted(sessionId: string): boolean { + return this.associations.has(sessionId) || this.childThreads.has(sessionId); + } + + nextToolCallId(sessionId: string, serverName: string): string { + const sequence = (this.interactionSequences.get(sessionId) ?? 0) + 1; + this.interactionSequences.set(sessionId, sequence); + return `${sessionId}:elicitation:${encodeURIComponent(serverName)}:${sequence}`; + } + + get(sessionId: string): SubagentAssociation | undefined { return this.associations.get(sessionId); } + forRoot(rootSessionId: string): SubagentAssociation[] { + return [...this.associations.values()].filter(child => child.rootSessionId === rootSessionId); + } +} diff --git a/src/subagents/SubagentEventRouter.ts b/src/subagents/SubagentEventRouter.ts new file mode 100644 index 00000000..dd2041f1 --- /dev/null +++ b/src/subagents/SubagentEventRouter.ts @@ -0,0 +1,6 @@ +import type {CodexSubagentEventRouter} from "./CodexSubagentEventRouter"; + +/** AIR and standard sessions expose the same routing surface, not the same lifetime rules. */ +export type SubagentEventRouter = Pick & { + withUserAction?(sessionId: string, itemId: string | undefined, request: () => Promise, turnId?: string | null): Promise; +}; diff --git a/src/subagents/SubagentInteraction.ts b/src/subagents/SubagentInteraction.ts new file mode 100644 index 00000000..05fa56b3 --- /dev/null +++ b/src/subagents/SubagentInteraction.ts @@ -0,0 +1,60 @@ +import {AsyncLocalStorage} from "node:async_hooks"; +import type {AcpV2ForegroundState} from "../AcpV2ForegroundState"; + +type Interaction = { + sessionId: string; + signal: AbortSignal; + foreground?: AcpV2ForegroundState; + itemId?: string; + nextToolCallId: (serverName: string) => string; + beforeRequest: () => Promise; +}; + +const interactions = new AsyncLocalStorage(); + +export function runSubagentInteraction(context: Interaction, request: () => Promise): Promise { + return interactions.run(context, request); +} + +export function subagentInteractionSignal(sessionId: string, fallback?: AbortSignal): AbortSignal | undefined { + const context = interactions.getStore(); + return context?.sessionId === sessionId ? context.signal : fallback; +} + +export function subagentToolCallId(sessionId: string, serverName: string): string | undefined { + const context = interactions.getStore(); + return context?.sessionId === sessionId ? context.nextToolCallId(serverName) : undefined; +} + +/** Only requests actually sent to the client count as blocked work. */ +export function withSubagentUserAction( + sessionId: string, request: () => Promise, itemId?: string, signal?: AbortSignal, +): Promise { + const context = interactions.getStore(); + if (context?.sessionId !== sessionId) return request(); + const cancellation = signal ?? context.signal; + const send = async (): Promise => { + cancellation.throwIfAborted(); + await context.beforeRequest(); + cancellation.throwIfAborted(); + let abort!: () => void; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(cancellation.reason); + cancellation.addEventListener("abort", abort, {once: true}); + }); + try { + return await Promise.race([ + Promise.resolve().then(() => { + cancellation.throwIfAborted(); + return request(); + }), + cancelled, + ]); + } finally { + cancellation.removeEventListener("abort", abort); + } + }; + return context.foreground + ? context.foreground.withUserAction(itemId ?? context.itemId, send, cancellation) + : send(); +} diff --git a/src/subagents/SubagentInteractionHandlers.ts b/src/subagents/SubagentInteractionHandlers.ts new file mode 100644 index 00000000..9272558d --- /dev/null +++ b/src/subagents/SubagentInteractionHandlers.ts @@ -0,0 +1,26 @@ +import type * as acp from "@agentclientprotocol/sdk/unstable"; +import type {AcpClientConnection} from "../ACPSessionConnection"; +import type {ServerNotification} from "../app-server"; +import {CodexElicitationHandler} from "../CodexElicitationHandler"; +import {CodexApprovalHandler} from "../permissions/CodexApprovalHandler"; +import {PermissionLifecycleContext, type PermissionPromptContext} from "../permissions/lifecycle"; +import {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; + +/** Child interactions outlive the parent's prompt and attachment handlers. */ +export class SubagentInteractionHandlers { + private readonly context: PermissionPromptContext; + readonly approvals: CodexApprovalHandler; + readonly elicitations: CodexElicitationHandler; + + constructor(connection: AcpClientConnection, rootSessionId: string, capabilities: acp.ClientCapabilities | null, renderer: AcpToolCallRenderer) { + this.context = new PermissionLifecycleContext({sessionId: rootSessionId}).beginPrompt(); + this.approvals = new CodexApprovalHandler(connection, this.context, undefined, renderer); + this.elicitations = new CodexElicitationHandler(connection, this.context, capabilities, undefined, renderer); + } + + async observe(event: ServerNotification, sessionId: string): Promise { + const scoped = {...event, params: {...event.params, threadId: sessionId}} as ServerNotification; + this.context.handleNotification(scoped); + await this.elicitations.handleNotification(scoped); + } +}