From 5f318be99d8011e5576802bcb213d06bb9fa6f6e Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 20 Oct 2026 22:12:17 +0200 Subject: [PATCH 1/5] feat: support standard ACP subagent sessions --- readme-dev.md | 34 +- src/ACPSessionConnection.ts | 13 + src/AcpAgentRouter.ts | 82 ++- src/AcpSessionExtensions.ts | 3 +- src/AcpV2ForegroundState.ts | 20 +- src/AcpV2SessionUpdate.ts | 5 +- src/CodexAcpClient.ts | 21 + src/CodexAcpServer.ts | 220 +++++++- src/CodexAppServerClient.ts | 12 + src/CodexElicitationHandler.ts | 36 +- src/CodexEventHandler.ts | 61 ++- src/__tests__/AcpV2ForegroundState.test.ts | 13 + .../data/standard-subagents-v1-cancel.json | 32 ++ .../standard-subagents-v1-disconnected.json | 57 ++ .../standard-subagents-v1-elicitation.json | 42 ++ .../data/standard-subagents-v1-fork.json | 20 + .../data/standard-subagents-v1-guards.json | 50 ++ .../data/standard-subagents-v1-identity.json | 75 +++ .../data/standard-subagents-v1-nested.json | 41 ++ ...andard-subagents-v1-permission-replay.json | 118 +++++ .../standard-subagents-v1-permission.json | 83 +++ .../standard-subagents-v1-plan-title.json | 58 ++ .../standard-subagents-v1-replay-live.json | 47 ++ .../data/standard-subagents-v1-replay.json | 33 ++ .../data/standard-subagents-v1-unknown.json | 31 ++ .../data/standard-subagents-v2-cancel.json | 47 ++ .../standard-subagents-v2-disconnected.json | 74 +++ .../standard-subagents-v2-elicitation.json | 64 +++ .../data/standard-subagents-v2-fork.json | 20 + .../data/standard-subagents-v2-guards.json | 50 ++ .../data/standard-subagents-v2-identity.json | 105 ++++ .../data/standard-subagents-v2-nested.json | 48 ++ ...andard-subagents-v2-permission-replay.json | 150 ++++++ .../standard-subagents-v2-permission.json | 112 ++++ .../standard-subagents-v2-plan-title.json | 73 +++ .../standard-subagents-v2-replay-live.json | 63 +++ .../data/standard-subagents-v2-replay.json | 49 ++ .../data/standard-subagents-v2-unknown.json | 45 ++ .../CodexACPAgent/standard-subagents.test.ts | 449 ++++++++++++++++ .../CodexACPAgent/v2-prompt-harness.ts | 36 +- .../foreground-v2-unknown-late-answer.json | 17 + .../integration/mock-responses-server.ts | 16 +- .../integration/standard-subagents.test.ts | 105 ++++ src/permissions/CodexApprovalHandler.ts | 8 +- src/subagents/AcpSubagents.ts | 29 +- src/subagents/CodexSubagentSubscriptions.ts | 39 +- src/subagents/StandardSubagentEventRouter.ts | 498 ++++++++++++++++++ src/subagents/SubagentAssociations.ts | 60 +++ src/subagents/SubagentEventRouter.ts | 6 + src/subagents/SubagentInteraction.ts | 61 +++ vitest.config.ts | 11 + 51 files changed, 3311 insertions(+), 131 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-cancel.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-disconnected.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-elicitation.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-fork.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-guards.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-identity.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-nested.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-permission-replay.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-permission.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-plan-title.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-replay-live.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-replay.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v1-unknown.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-cancel.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-disconnected.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-elicitation.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-fork.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-guards.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-identity.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-nested.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-permission-replay.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-permission.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-plan-title.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-replay-live.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-replay.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-subagents-v2-unknown.json create mode 100644 src/__tests__/CodexACPAgent/standard-subagents.test.ts create mode 100644 src/__tests__/data/foreground-v2-unknown-late-answer.json create mode 100644 src/__tests__/integration/standard-subagents.test.ts create mode 100644 src/subagents/StandardSubagentEventRouter.ts create mode 100644 src/subagents/SubagentAssociations.ts create mode 100644 src/subagents/SubagentEventRouter.ts create mode 100644 src/subagents/SubagentInteraction.ts create mode 100644 vitest.config.ts diff --git a/readme-dev.md b/readme-dev.md index 698913de5..3d1384d96 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -183,6 +183,38 @@ turn. Item completion still reports compaction progress, but does not release th The v1 prompt response and v2 idle state wait for the turn to finish; v2's early prompt-acceptance response is unchanged. +#### Standard subagent sessions + +Non-AIR v1 clients opt in with `clientCapabilities.subagents: {}`. On v2, standard subagent +sessions need no feature marker. AIR retains its existing private opt-in, fallback, updates, and +generation-based identities; a connection receives one contract, not both. The v1 marker used by +older AIR clients also keeps that existing behavior. + +Standard `subagent_update` associations appear in the immediate parent's session before any child +traffic. A child keeps its opaque ACP session ID across delegations, idle work, and replay. +Nested children are not flattened under the root; traffic without sufficient parentage is buffered +until the ancestor chain is known. Forked histories get a separate child identity namespace. + +Work state is independent of conversation lifetime. An observed work ending reports `idle` with +its stop reason, not a permanently completed child. Lost observability reports `unknown`, not a +fabricated failure. On v2, the same state also appears on the child's own `state_update` stream. +Child work, plans, permissions, and elicitation can outlive the parent's foreground prompt. +Child Markdown plans use completed snapshots rather than interim draft deltas. +Parent cancellation interrupts observed descendant work and withdraws child interactions; late +answers cannot restart finished work. Closing or replacing the runtime invalidates those scopes. + +Children initially advertise empty capabilities. Client-initiated prompts, load/resume/fork, +cancel, close/delete, configuration changes, and private session mutations are not enabled. +Responding to child-scoped Agent-to-Client requests is still allowed. Replayed associations and +transcripts do not grant controls or confirm current activity; overlapping completed items are +deduplicated and fresh work snapshots follow the replay response. A copied history alone never +authorizes interrupting the original child runtime. +Load/resume requests for the same standard parent are serialized through this response boundary; +close and cancel remain independent, and a close invalidates already queued setups. + +Dedicated `session_message` reporting and optional child controls remain follow-ups. Ordinary +runtime tool reports remain available for operations such as waiting and follow-up tasks. + #### AIR v2 client contract These are behaviors the AIR (JetBrains) ACP client relies on that aren't obvious from the wire @@ -203,7 +235,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 diff --git a/src/ACPSessionConnection.ts b/src/ACPSessionConnection.ts index 73c5c09cc..994c20778 100644 --- a/src/ACPSessionConnection.ts +++ b/src/ACPSessionConnection.ts @@ -248,6 +248,19 @@ export class ACPSessionConnection { await this.v2Connection.updateState(this.sessionId, state); } + /** Standard children carry one state snapshot on their own v2 stream and their parent's roster. */ + 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 b420c37ad..b2052bed0 100644 --- a/src/AcpAgentRouter.ts +++ b/src/AcpAgentRouter.ts @@ -67,12 +67,38 @@ export function createAcpAgentRouter( createAgent: (connection: AcpClientConnection | AcpV2Connection) => CodexAcpServer, ): 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; }; + const setupTails = new Map>(); + const setup = (sessionId: string, operation: () => Promise, signal: AbortSignal): Promise => { + const agent = getAgent(); + if (!agent.usesStandardSubagents()) return operation(); + const assertCurrent = agent.captureSessionSetup(sessionId); + let resolve!: (response: T) => void; + let reject!: (error: unknown) => void; + const response = new Promise((done, failed) => { resolve = done; reject = failed; }); + // A following setup must not consume the previous setup's deferred child traffic. + const task = (setupTails.get(sessionId) ?? Promise.resolve()).catch(() => {}).then(async () => { + try { + if (signal.aborted) throw new acp.RequestError(-32800, "Session setup was cancelled"); + assertCurrent(); + resolve(await operation()); + } catch (error) { + reject(error); + } + await agent.afterSessionSetupResponse(sessionId); + }); + setupTails.set(sessionId, task); + void task.finally(() => { + if (setupTails.get(sessionId) === task) setupTails.delete(sessionId); + }).catch(() => {}); + return response; + }; const attachAgent = (agent: CodexAcpServer, signal: AbortSignal) => { codexAcpServer = agent; signal.addEventListener("abort", () => { @@ -87,32 +113,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, () => 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, () => 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 +148,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, () => 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/AcpSessionExtensions.ts b/src/AcpSessionExtensions.ts index 86e16daf2..d27c3f474 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 cab617e88..79360fc55 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)); diff --git a/src/AcpV2SessionUpdate.ts b/src/AcpV2SessionUpdate.ts index c4fb072ec..cec7127d1 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 existing 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 08b59998c..e2e5a1fec 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -826,6 +826,16 @@ export class CodexAcpClient { return null; } + /** A standard child is one conversation, not one synthetic session per delegation. */ + async readSessionItems(sessionId: string): Promise> { + const metadata = await this.codexClient.threadRead({threadId: sessionId}); + if (metadata.thread.historyMode === "legacy") { + const legacy = await this.codexClient.threadRead({threadId: sessionId, includeTurns: true}); + return oneItemPage(legacy.thread.turns.flatMap(turn => turn.items)); + } + return this.codexClient.threadItemPages(sessionId); + } + async newSession(request: WithAcpMcpServers): Promise { const instructions = await this.resolveSessionInstructions(request, undefined, true); const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); @@ -1235,6 +1245,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)); @@ -1253,9 +1265,18 @@ 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); + } + async waitForSessionNotifications(sessionId: string): Promise { while (true) { const queue = this.sessionNotificationQueues.get(sessionId); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 27d739e10..8e5d5ab2e 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -167,11 +167,14 @@ import { toThreadGoalSnapshot, } from "./ThreadGoalSnapshot"; import { - clientSupportsSubagents, + subagentProtocol, type SubagentAwareSessionCapabilities, } from "./subagents/AcpSubagents"; import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; -import {nameFromAgentPath} from "./subagents/CodexAgentPath"; +import {StandardSubagentEventRouter} from "./subagents/StandardSubagentEventRouter"; +import {SubagentAssociations} from "./subagents/SubagentAssociations"; +import type {SubagentEventRouter} from "./subagents/SubagentEventRouter"; +import {isRootAgentPath, nameFromAgentPath} from "./subagents/CodexAgentPath"; import {listCodexHooks, trustCodexHooks, type CodexHookIdentity} from "./CodexHookTrust"; import type {HooksListEntry} from "./app-server/v2/HooksListEntry"; import { @@ -273,7 +276,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; @@ -499,6 +503,7 @@ export class CodexAcpServer { private authStatusVersion = 0; private readonly sessions: Map; + private readonly subagentAssociations = new SubagentAssociations(); private readonly mcpSessionStartup: McpSessionStartup; private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; @@ -625,6 +630,84 @@ 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; + // Child requests and work belong to the conversation, not a particular load or prompt handler. + if (this.usesStandardSubagents() && existing instanceof StandardSubagentEventRouter && !existing.isRetired()) { + return existing; + } + const session = new ACPSessionConnection(this.connection, sessionId); + const release = (childId: string) => this.reportingConnection.reports.releaseOpen(childId); + return this.usesStandardSubagents() + ? new StandardSubagentEventRouter(sessionId, session, this.subagentAssociations, release, this.capabilities, replay, + this.sessions.get(sessionId)?.subagentTransition) + : new CodexSubagentEventRouter(sessionId, this.supportsNativeSubagents(), session, release); + } + + /** Called at the router boundary, never for the parent's internal runtime operations. */ + assertSessionMutationAllowed(sessionId: string): void { + if (this.subagentAssociations.isRestricted(sessionId)) { + throw RequestError.invalidRequest(undefined, "Subagent sessions do not permit client-initiated mutations"); + } + } + + 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 { + // Resolve the RPC first; release traffic on the next event-loop turn, then unblock the next setup. + return new Promise(resolve => setImmediate(() => { + const router = this.sessions.get(sessionId)?.subagents; + if (!(router instanceof StandardSubagentEventRouter) || this.sessionIsClosing(sessionId)) return resolve(); + this.codexAcpClient.redispatchSessionNotifications( + sessionId, router.releaseReplay(), () => router.publishReplaySnapshots(), + ); + void this.codexAcpClient.waitForSessionNotifications(sessionId).then(resolve, error => { + logger.error("Failed to release child traffic after session setup", error); + resolve(); + }); + })); + } + + 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 { @@ -984,6 +1067,8 @@ export class CodexAcpServer { } private beginSessionOpen(sessionId: string): number { + const subagents = this.sessions.get(sessionId)?.subagents; + if (subagents instanceof StandardSubagentEventRouter) subagents.pauseForSetup(); const generation = this.getSessionGeneration(sessionId); if (this.sessionIsClosing(sessionId)) { throw RequestError.invalidRequest(`Session ${sessionId} is closing`); @@ -1208,12 +1293,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), - ), + subagents: this.createSubagentRouter(sessionId), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), toolCallReports: this.reportingConnection.reports, @@ -1467,9 +1547,14 @@ export class CodexAcpServer { }, DENY_ALL_APPROVALS, DENY_ALL_ELICITATIONS, - clientSupportsSubagents(this.clientCapabilities), + this.supportsNativeSubagents(), () => {}, async () => null, + async (childId, itemId, request, turnId) => { + const state = await ready; + return state?.subagents.withUserAction?.(childId, itemId, request, turnId) ?? request(); + }, + event => this.observeSubagentWorkEnd(sessionId, event), ); } @@ -1816,13 +1901,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.subagentAssociations.isRestricted(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.subagentAssociations.isRestricted(session.sessionId)).map((session) => { const activeSession = this.sessions.get(session.sessionId); if (!activeSession || activeSession.additionalDirectories.length === 0) { return session; @@ -1846,6 +1932,9 @@ 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}); @@ -2083,6 +2172,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"); @@ -2108,6 +2200,9 @@ 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 @@ -2264,6 +2359,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); }); @@ -3266,12 +3366,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), - ), + subagents: this.createSubagentRouter(sessionId, true), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), toolCallReports: this.reportingConnection.reports, @@ -3345,7 +3440,11 @@ export class CodexAcpServer { this.protocolVersion === 2 ? withoutReviewerPrompts(entryPages) : itemsOfEntries(entryPages), isOpen, ); - if (clientSupportsSubagents(this.clientCapabilities)) { + if (this.usesStandardSubagents()) { + await this.streamStandardThreadHistory(sessionId, itemPages, sessionState, new Set([sessionId]), isOpen); + return; + } + if (this.supportsNativeSubagents()) { await this.streamNativeThreadHistory( sessionId, itemPages, @@ -3521,6 +3620,71 @@ export class CodexAcpServer { } } + private async streamStandardThreadHistory( + sessionId: string, + itemPages: AsyncIterable, + sessionState: SessionState, + ancestry: Set, + isOpen: () => boolean, + threadId: string = sessionId, + replayedChildren = new Set(), + ): Promise { + const router = sessionState.subagents; + if (!(router instanceof StandardSubagentEventRouter)) return; + const session = new ACPSessionConnection(this.connection, sessionId); + const started = new Set(); + const replayChild = async (item: ThreadItem & {type: "subAgentActivity"}) => { + if (isRootAgentPath(item.agentPath) || ancestry.has(item.agentThreadId)) return; + const childId = await router.announceHistory(item.agentThreadId, item.agentPath); + if (childId === null || replayedChildren.has(item.agentThreadId)) return; + replayedChildren.add(item.agentThreadId); + try { + const history = await this.codexAcpClient.readSessionItems(item.agentThreadId); + await this.streamStandardThreadHistory( + childId, history, sessionState, new Set([...ancestry, item.agentThreadId]), + isOpen, item.agentThreadId, replayedChildren, + ); + } catch (error) { + if (error instanceof SessionClosedDuringLoadError) throw error; + logger.error(`Failed to replay subagent ${item.agentThreadId}`, error); + } + }; + const unresolved: Array = []; + for await (const items of itemPages) { + for (const item of items) { + if (!isOpen()) throw new SessionClosedDuringLoadError(); + // Native child input comes from agents, not ordinary client user messages. + if (threadId !== sessionState.sessionId && item.type === "userMessage") continue; + if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") { + if (item.senderThreadId === threadId) { + for (const child of item.receiverThreadIds) { + if (child.trim() && !ancestry.has(child)) { + await router.announce(child, sessionId, undefined, item.prompt ?? undefined); + } + } + } + continue; + } + if (item.type === "subAgentActivity") { + await replayChild(item); + if (!replayedChildren.has(item.agentThreadId)) unresolved.push(item); + continue; + } + const partial = "status" in item && item.status === "inProgress"; + // Running commands may overlap buffered live output; replay their metadata, not bytes twice. + const replayItem = item.type === "commandExecution" && partial ? {...item, aggregatedOutput: null} : item; + for (const update of await this.createHistoryUpdates(replayItem, sessionState)) { + if (this.protocolVersion === 2) await this.sendReplayMessageStart(session, update, started); + await session.update(update); + } + if (threadId !== sessionState.sessionId && !partial) router.rememberReplayedItem(threadId, item.id); + } + } + for (const item of unresolved) await replayChild(item); + if (!isOpen()) throw new SessionClosedDuringLoadError(); + // History-only children have no confirmed current state or enabled mutations. + } + private async publishThreadHistoryTitle( session: ACPSessionConnection, sessionState: SessionState, @@ -3996,6 +4160,7 @@ export class CodexAcpServer { turnId: turn.turnId, }); try { + if (sessionState.subagents instanceof StandardSubagentEventRouter) await this.interruptStandardChildren(sessionState); await this.requestTurnInterrupt(sessionState, turn.threadId, turn.turnId, requestName); } finally { this.codexAcpClient.resolveTurnInterrupted({ @@ -4086,6 +4251,7 @@ export class CodexAcpServer { requestName: "Cancel" | "Close", resolveInterruptedTurn: boolean, ): Promise { + if (sessionState.subagents instanceof StandardSubagentEventRouter) await this.interruptStandardChildren(sessionState); const turnId = await this.getInterruptibleTurnId(sessionState, requestName); if (!turnId) { return; @@ -4527,8 +4693,13 @@ export class CodexAcpServer { ); promptElicitations = elicitationHandler; const observeInteraction = async (event: ServerNotification): Promise => { - permissionContext.handleNotification(event); - await elicitationHandler.handleNotification(event); + const scoped = sessionState.subagents instanceof StandardSubagentEventRouter + && typeof (event.params as {threadId?: unknown}).threadId === "string" + && sessionState.subagents.isNativeSubagentThread((event.params as {threadId: string}).threadId) + ? {...event, params: {...event.params, threadId: sessionState.subagents.notificationSessionId(event)}} as ServerNotification + : event; + permissionContext.handleNotification(scoped); + await elicitationHandler.handleNotification(scoped); }; const resolvePendingInsertion = async (): Promise => { if (pendingInsertion === undefined) { @@ -4577,9 +4748,11 @@ export class CodexAcpServer { }, approvalHandler, elicitationHandler, - clientSupportsSubagents(this.clientCapabilities), + 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(); @@ -5231,6 +5404,7 @@ export class CodexAcpServer { } async cancel(params: acp.CancelNotification): Promise { + if (this.subagentAssociations.isRestricted(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 fdf3ad3c7..ae54094ef 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); } @@ -1029,6 +1037,10 @@ 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 2e50cc20d..e0a56ec59 100644 --- a/src/CodexElicitationHandler.ts +++ b/src/CodexElicitationHandler.ts @@ -27,6 +27,7 @@ import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer"; 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"; +import {subagentInteractionSignal, subagentToolCallId, withSubagentUserAction} from "./subagents/SubagentInteraction"; type AcpBackedMcpElicitationParams = Extract< McpServerElicitationRequestParams, { mode: "form" } | { mode: "url" } @@ -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 6bdb1158a..ac62a1101 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -61,7 +61,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"; @@ -239,7 +240,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; @@ -248,7 +249,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, @@ -426,6 +427,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 @@ -437,9 +441,21 @@ export class CodexEventHandler { this.recordChildTurnDiff(notification.params); } await this.flushPendingErrors(); + if (this.subagents instanceof StandardSubagentEventRouter + && typeof threadId === "string" && threadId !== this.sessionState.sessionId + && !this.subagents.isNativeSubagentThread(threadId)) { + await this.subagents.handle(notification); + for (const event of this.subagents.takeBufferedNotifications()) await this.handleNotification(event); + return; + } await this.finishCompactionsForNotification(notification); const closingChildren = this.subagents.closingChildSessions(notification); for (const child of closingChildren) { + if (this.subagents instanceof StandardSubagentEventRouter) { + for (const update of this.subagents.openToolCalls(child.threadId)?.finishOutstanding() ?? []) { + await this.session.update(update, child.sessionId); + } + } await this.finishOutstandingCompactions( child.state === "cancelled" ? "cancelled" : "failed", child.sessionId, @@ -565,27 +581,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); 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); @@ -658,10 +674,10 @@ export class CodexEventHandler { closed: true, }); case "item/commandExecution/outputDelta": - this.completeRetryIncidentOnTurnProgress(); + this.completeRetryIncidentOnTurnProgress(notification.params.threadId); return this.renderer.render(this.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( @@ -693,13 +709,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); @@ -1012,10 +1028,16 @@ 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): 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); } private async updatePlan(event: TurnPlanUpdatedNotification): Promise { @@ -1144,7 +1166,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/__tests__/AcpV2ForegroundState.test.ts b/src/__tests__/AcpV2ForegroundState.test.ts index ca87a4c43..d1af78332 100644 --- a/src/__tests__/AcpV2ForegroundState.test.ts +++ b/src/__tests__/AcpV2ForegroundState.test.ts @@ -15,6 +15,19 @@ function fixture() { } describe("ACP v2 aggregate foreground state", () => { + it("invalidates old interaction waits when observability is lost", async () => { + const {foreground, updates} = fixture(); + await foreground.start(); + const wait = deferred(); + const action = foreground.withUserAction(undefined, () => wait.promise); + await foreground.snapshot(); + await foreground.update({state: "unknown"}); + wait.resolve(); + await action; + await foreground.snapshot(); + await expect(JSON.stringify(updates, null, 2)).toMatchFileSnapshot("data/foreground-v2-unknown-late-answer.json"); + }); + it("keeps running while another tool can progress, then tracks which waits still block", async () => { const {foreground, updates} = fixture(); await foreground.start(); diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v1-cancel.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-cancel.json new file mode 100644 index 000000000..89e301b4a --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-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": "cancelled", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v1-disconnected.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-disconnected.json new file mode 100644 index 000000000..8d523610b --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-disconnected.json @@ -0,0 +1,57 @@ +[ + { + "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": "old-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "old-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "_meta": { + "terminal_info": { + "cwd": "/workspace", + "terminal_id": "old-command" + } + }, + "sessionUpdate": "tool_call" + } + }, + { + "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/standard-subagents-v1-elicitation.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-elicitation.json new file mode 100644 index 000000000..2a0e9ea7d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-elicitation.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": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "requires_action" + }, + "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/standard-subagents-v1-fork.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-fork.json new file mode 100644 index 000000000..d7a141007 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-fork.json @@ -0,0 +1,20 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "fork", + "update": { + "sessionId": "fork:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v1-guards.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-guards.json new file mode 100644 index 000000000..82bcb27af --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-guards.json @@ -0,0 +1,50 @@ +[ + { + "method": "session/prompt", + "code": -32600 + }, + { + "method": "session/resume", + "code": -32600 + }, + { + "method": "session/fork", + "code": -32600 + }, + { + "method": "session/close", + "code": -32600 + }, + { + "method": "session/delete", + "code": -32600 + }, + { + "method": "session/set_config_option", + "code": -32600 + }, + { + "method": "_session/steering", + "code": -32600 + }, + { + "method": "_session/goal", + "code": -32600 + }, + { + "method": "_session/async_task/stop", + "code": -32600 + }, + { + "method": "_session/rename", + "code": -32600 + }, + { + "method": "_session/archive", + "code": -32600 + }, + { + "method": "_session/unarchive", + "code": -32600 + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v1-identity.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-identity.json new file mode 100644 index 000000000..c5cec13f9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-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 result", + "type": "text" + }, + "messageId": "child-message-1", + "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 result", + "type": "text" + }, + "messageId": "child-message-2", + "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/standard-subagents-v1-nested.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-nested.json new file mode 100644 index 000000000..5593b526b --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-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:nested", + "title": "Nested", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:nested", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:nested", + "update": { + "content": { + "text": "Nested result", + "type": "text" + }, + "messageId": "nested-message", + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v1-permission-replay.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-permission-replay.json new file mode 100644 index 000000000..b3c4b4a11 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-permission-replay.json @@ -0,0 +1,118 @@ +[ + { + "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": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "_meta": { + "terminal_info": { + "cwd": "/workspace", + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "requires_action" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "_meta": { + "terminal_info": { + "cwd": "/workspace", + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "failed", + "_meta": { + "terminal_exit": { + "exit_code": null, + "signal": null, + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call_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/standard-subagents-v1-permission.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-permission.json new file mode 100644 index 000000000..858caacc1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-permission.json @@ -0,0 +1,83 @@ +[ + { + "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": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "_meta": { + "terminal_info": { + "cwd": "/workspace", + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "requires_action" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "failed", + "_meta": { + "terminal_exit": { + "exit_code": null, + "signal": null, + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call_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/standard-subagents-v1-plan-title.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-plan-title.json new file mode 100644 index 000000000..60ced575d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-plan-title.json @@ -0,0 +1,58 @@ +[ + { + "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": { + "title": "Worker title", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker title", + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "A child plan", + "type": "text" + }, + "messageId": "child-plan", + "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/standard-subagents-v1-replay-live.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-replay-live.json new file mode 100644 index 000000000..7ebfe97dd --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-replay-live.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": { + "content": { + "text": "Historical result", + "type": "text" + }, + "messageId": "persisted-reply", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "response": "replay complete" + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "New result", + "type": "text" + }, + "messageId": "live-reply", + "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/standard-subagents-v1-replay.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-replay.json new file mode 100644 index 000000000..79e593c8c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-replay.json @@ -0,0 +1,33 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Historical result", + "type": "text" + }, + "messageId": "persisted-reply", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Historical follow-up", + "type": "text" + }, + "messageId": "second-reply", + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v1-unknown.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-unknown.json new file mode 100644 index 000000000..f814fbe8c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v1-unknown.json @@ -0,0 +1,31 @@ +[ + { + "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": { + "state": "unknown" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v2-cancel.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-cancel.json new file mode 100644 index 000000000..eff00c182 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-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": "cancelled", + "state": "idle", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "stopReason": "cancelled", + "state": "idle" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v2-disconnected.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-disconnected.json new file mode 100644 index 000000000..9a1c1f480 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-disconnected.json @@ -0,0 +1,74 @@ +[ + { + "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": "old-command", + "command": "sleep 12", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "old-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "old-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_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/data/standard-subagents-v2-elicitation.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-elicitation.json new file mode 100644 index 000000000..1ae6e64dd --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-elicitation.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": { + "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" + } + }, + { + "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/standard-subagents-v2-fork.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-fork.json new file mode 100644 index 000000000..d7a141007 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-fork.json @@ -0,0 +1,20 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "fork", + "update": { + "sessionId": "fork:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v2-guards.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-guards.json new file mode 100644 index 000000000..82bcb27af --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-guards.json @@ -0,0 +1,50 @@ +[ + { + "method": "session/prompt", + "code": -32600 + }, + { + "method": "session/resume", + "code": -32600 + }, + { + "method": "session/fork", + "code": -32600 + }, + { + "method": "session/close", + "code": -32600 + }, + { + "method": "session/delete", + "code": -32600 + }, + { + "method": "session/set_config_option", + "code": -32600 + }, + { + "method": "_session/steering", + "code": -32600 + }, + { + "method": "_session/goal", + "code": -32600 + }, + { + "method": "_session/async_task/stop", + "code": -32600 + }, + { + "method": "_session/rename", + "code": -32600 + }, + { + "method": "_session/archive", + "code": -32600 + }, + { + "method": "_session/unarchive", + "code": -32600 + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v2-identity.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-identity.json new file mode 100644 index 000000000..ded91a0ad --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-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": "child-message-1", + "content": { + "text": "First result", + "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": "child-message-2", + "content": { + "text": "Second result", + "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/standard-subagents-v2-nested.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-nested.json new file mode 100644 index 000000000..bf00f7f0c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-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:nested", + "title": "Nested", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:nested", + "update": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:nested", + "state": { + "state": "running" + }, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:nested", + "update": { + "messageId": "nested-message", + "content": { + "text": "Nested result", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v2-permission-replay.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-permission-replay.json new file mode 100644 index 000000000..bcbdc572f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-permission-replay.json @@ -0,0 +1,150 @@ +[ + { + "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": "child-command", + "command": "sleep 12", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_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" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "command": "sleep 12", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "exitStatus": { + "exitCode": null, + "signal": null + }, + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "failed", + "sessionUpdate": "tool_call_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/standard-subagents-v2-permission.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-permission.json new file mode 100644 index 000000000..68fc780c0 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-permission.json @@ -0,0 +1,112 @@ +[ + { + "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": "child-command", + "command": "sleep 12", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_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" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "exitStatus": { + "exitCode": null, + "signal": null + }, + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "failed", + "sessionUpdate": "tool_call_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/standard-subagents-v2-plan-title.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-plan-title.json new file mode 100644 index 000000000..945db8211 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-plan-title.json @@ -0,0 +1,73 @@ +[ + { + "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": { + "title": "Worker title", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker title", + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "plan": { + "planId": "child-plan", + "content": "A child plan", + "type": "markdown" + }, + "sessionUpdate": "plan_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/standard-subagents-v2-replay-live.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-replay-live.json new file mode 100644 index 000000000..1c5f81c18 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-replay-live.json @@ -0,0 +1,63 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "persisted-reply", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "persisted-reply", + "content": { + "text": "Historical result", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "response": "replay complete" + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "live-reply", + "content": { + "text": "New result", + "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/standard-subagents-v2-replay.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-replay.json new file mode 100644 index 000000000..628dae2c2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-replay.json @@ -0,0 +1,49 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "persisted-reply", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "persisted-reply", + "content": { + "text": "Historical result", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "second-reply", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "second-reply", + "content": { + "text": "Historical follow-up", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-subagents-v2-unknown.json b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-unknown.json new file mode 100644 index 000000000..1f93567b2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-subagents-v2-unknown.json @@ -0,0 +1,45 @@ +[ + { + "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": { + "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/standard-subagents.test.ts b/src/__tests__/CodexACPAgent/standard-subagents.test.ts new file mode 100644 index 000000000..bb82ba9dc --- /dev/null +++ b/src/__tests__/CodexACPAgent/standard-subagents.test.ts @@ -0,0 +1,449 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import type {ClientCapabilities} from "@agentclientprotocol/sdk/unstable"; +import type {ServerNotification} from "../../app-server"; +import type {ThreadItem} from "../../app-server/v2"; +import {SubagentAssociations} from "../../subagents/SubagentAssociations"; +import {subagentProtocol} from "../../subagents/AcpSubagents"; +import {AIR_NATIVE_SUBAGENT_SESSIONS_KEY, withAirMeta} from "../../AirExtension"; +import {GOAL_CONTROL_METHOD, SESSION_STEERING_METHOD} from "../../AcpExtensions"; +import {ASYNC_TASK_STOP_METHOD} from "../../async-tasks/AsyncTaskExtension"; +import {SESSION_ARCHIVE_METHOD, SESSION_RENAME_METHOD, SESSION_UNARCHIVE_METHOD} from "../../SessionIndex"; +import { + agentMessageDelta, commandExecutionItem, connectSession, codexResponse, createThread, createTurn, cwd, + itemStarted, sessionId, titleThreadId, turnCompleted, turnStarted, userMessageItem, + type PromptSession, +} from "./v2-prompt-harness"; +import {expectConformingV2SessionUpdates} from "./v2-session-update-guard"; + +const child = "worker"; +const childId = `${sessionId}:subagent:${child}`; +const capabilities: ClientCapabilities = {subagents: {}}; + +function onThread(notification: ServerNotification, threadId: string): ServerNotification { + return {...notification, params: {...notification.params, threadId}} as ServerNotification; +} + +function activity(id = child, path = "/root/worker", kind: "started" | "completed" | "interacted" = "started"): ServerNotification { + return itemStarted({type: "subAgentActivity", id: `activity-${id}-${kind}`, kind, agentThreadId: id, agentPath: path}); +} + +async function flush(harness: PromptSession): Promise { + await harness.waitForNotifications(); + // A response on the same stream is a barrier for previously queued outbound notifications. + await harness.request("session/list", {}); +} + +function childUpdates(harness: PromptSession) { + return harness.scopedUpdates.filter(entry => + entry.sessionId.includes(":subagent:") || entry.update.sessionUpdate === "subagent_update"); +} + +function hasText(update: PromptSession["scopedUpdates"][number]["update"], text: string): boolean { + const content = "content" in update ? update.content : null; + return update.sessionUpdate === "agent_message_chunk" && typeof content === "object" && content !== null + && "type" in content && content.type === "text" && "text" in content && content.text === text; +} + +function history(h: PromptSession, rootItems: ThreadItem[], childTurns: ThreadItem[][], onRead?: () => void | Promise) { + h.setCodexResponse("thread/resume", async params => { + const id = (params as {threadId: string}).threadId; + return {...codexResponse("thread/start") as object, thread: {...createThread(), id, sessionId: id}}; + }); + h.setCodexResponse("thread/read", async params => { + const {threadId: id, includeTurns} = params as {threadId: string; includeTurns?: boolean}; + if (id === child && includeTurns) await onRead?.(); + const turns = id === child ? childTurns : [rootItems]; + return {thread: {...createThread(), id, sessionId: id, turns: turns.map((items, index) => ({ + ...createTurn("completed", `${id}-turn-${index}`), itemsView: "full", items, + }))}}; + }); +} + +async function snapshot(harness: PromptSession, name: string) { + await flush(harness); + await expect(JSON.stringify(childUpdates(harness), null, 2)).toMatchFileSnapshot(`data/${name}.json`); +} + +describe("standard subagent protocol selection", () => { + it("selects one contract and retains AIR's opt-ins and fallback", () => { + const air = {_meta: withAirMeta(undefined, "capabilities", [AIR_NATIVE_SUBAGENT_SESSIONS_KEY])}; + const airWithoutOptIn = {_meta: withAirMeta(undefined, "capabilities", [])}; + expect([ + subagentProtocol(null, 1), subagentProtocol(capabilities, 1), + subagentProtocol(null, 2), subagentProtocol(air, 1), subagentProtocol(air, 2), + subagentProtocol(airWithoutOptIn, 1), subagentProtocol(airWithoutOptIn, 2), + subagentProtocol({...airWithoutOptIn, ...capabilities}, 1), + ]).toEqual(["hidden", "standard", "standard", "air", "air", "hidden", "hidden", "air"]); + }); + + it("namespaces fork identities without ambiguous delimiters or cross-tree parents", () => { + const registry = new SubagentAssociations(); + const first = registry.register("root", "root", child); + const fork = registry.register("fork", "fork", child); + expect(first.sessionId).not.toBe(fork.sessionId); + expect(registry.sessionId("a", "b:subagent:c")).not.toBe(registry.sessionId("a:subagent:b", "c")); + expect(() => registry.register("fork", first.sessionId, "nested")).toThrow(); + expect(() => registry.register("root", "another-parent", child)).toThrow(); + expect(registry.isRestricted(child)).toBe(true); + expect(registry.nextToolCallId(first.sessionId, "mcp")) + .not.toBe(registry.nextToolCallId(first.sessionId, "mcp")); + }); +}); + +describe.each([1, 2] as const)("ACP v%s standard child sessions", version => { + let harness: PromptSession | undefined; + afterEach(() => { + harness?.connection.close(); + harness = undefined; + expectConformingV2SessionUpdates(); + }); + + async function connect(options: Parameters[1] = {}) { + harness = await connectSession(version, {v1ClientCapabilities: capabilities, ...options}); + return harness; + } + + async function delegate(h: PromptSession) { + const prompt = h.sendPrompt([{type: "text", text: "Delegate"}]); + await vi.waitFor(() => expect(h.turnStartParams).toHaveLength(1)); + h.emit(turnStarted()); + const id = h.turnStartParams[0]!["clientUserMessageId"]; + h.emit(itemStarted(userMessageItem(typeof id === "string" ? id : null, "Delegate"))); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + h.emit(turnCompleted()); + await prompt; + await h.promptRunFinished(); + } + + it("announces once and keeps the same identity across idle and later work", async () => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + h.emit(onThread(agentMessageDelta("First result", "child-first", "child-message-1"), child)); + h.emit(onThread(turnCompleted("child-first"), child)); + await flush(h); + h.emit(activity(child, "/root/worker", "interacted")); + h.emit(onThread(turnStarted("child-second"), child)); + // A late ending of the first turn cannot finish the new one. + h.emit(onThread(turnCompleted("child-first"), child)); + h.emit(onThread(agentMessageDelta("Second result", "child-second", "child-message-2"), child)); + h.emit(onThread(turnCompleted("child-second"), child)); + await snapshot(h, `standard-subagents-v${version}-identity`); + const announcements = childUpdates(h).filter(({update}) => update.sessionUpdate === "subagent_update" && "capabilities" in update); + expect(announcements).toHaveLength(1); + }); + + it("waits for truthful parentage and announces ancestors before buffered child traffic", async () => { + const h = await connect(); + h.emit(activity("nested", "/root/coordinator/nested")); + h.emit(onThread(turnStarted("nested-turn"), "nested")); + h.emit(onThread(agentMessageDelta("Nested result", "nested-turn", "nested-message"), "nested")); + await flush(h); + expect(childUpdates(h)).toEqual([]); + h.emit(activity("coordinator", "/root/coordinator")); + await snapshot(h, `standard-subagents-v${version}-nested`); + }); + + it("rejects unadvertised mutations, including private extensions, and ignores direct cancellation", async () => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + await flush(h); + const methods = [ + ["session/prompt", {prompt: [{type: "text", text: "Do not execute"}]}], + ["session/resume", {cwd, mcpServers: []}], + ["session/fork", {cwd, mcpServers: []}], + ["session/close", {}], ["session/delete", {}], + ["session/set_config_option", {configId: "model", value: "gpt-5", ...(version === 2 ? {type: "id"} : {})}], + [SESSION_STEERING_METHOD, {prompt: [{type: "text", text: "Do not execute"}]}], + [GOAL_CONTROL_METHOD, {action: "set", objective: "Do not execute"}], + [ASYNC_TASK_STOP_METHOD, {asyncTaskId: "task"}], + [SESSION_RENAME_METHOD, {title: "Do not execute"}], + [SESSION_ARCHIVE_METHOD, {}], [SESSION_UNARCHIVE_METHOD, {}], + ] as const; + const errors = []; + for (const [method, params] of methods) { + try { + await h.request(method, {sessionId: childId, ...params}); + throw new Error(`Unexpectedly accepted ${method}`); + } catch (error) { + errors.push({method, code: (error as {code?: number}).code}); + } + } + expect(errors).toEqual(methods.map(([method]) => ({method, code: -32600}))); + // A leaked provider id must not offer a second route around the child guard. + await expect(h.request("session/close", {sessionId: child})).rejects.toMatchObject({code: -32600}); + await h.agent.cancel({sessionId: childId}); + expect(h.turnInterruptCalls()).toEqual([]); + await expect(JSON.stringify(errors, null, 2)).toMatchFileSnapshot(`data/standard-subagents-v${version}-guards.json`); + }); + + it.each([false, true])("routes independent child permissions and withdraws obsolete replies (parent replay: %s)", async replay => { + let answer!: () => void; + let withdrawn = false; + const h = await connect({ + onRequestPermission: (request, signal) => new Promise(resolve => { + signal.addEventListener("abort", () => { withdrawn = true; }, {once: true}); + answer = () => resolve({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}); + }), + }); + await delegate(h); + h.emit(onThread(itemStarted(commandExecutionItem("child-command"), "child-first"), child)); + const permission = h.triggerApproval("item/commandExecution/requestApproval", { + threadId: child, turnId: "child-first", itemId: "child-command", + command: "echo hello", cwd, commandActions: [], reason: null, + availableDecisions: ["accept", "decline"], + }); + await vi.waitFor(() => expect(h.transcript.some(entry => "permissionRequest" in entry && entry.permissionRequest.sessionId === childId)).toBe(true)); + if (replay) { + // Replay waits for title generation too; finish that unrelated mocked turn. + await vi.waitFor(() => expect(h.appServer["pendingTurnCompletionResolvers"].get(titleThreadId)?.has("title-turn")).toBe(true)); + h.emit(onThread(turnCompleted("title-turn"), titleThreadId)); + history(h, [{type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}], + [[commandExecutionItem("child-command")]]); + await h.request(version === 1 ? "session/load" : "session/resume", { + sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + expect(withdrawn).toBe(false); + } + h.emit(onThread(turnCompleted("child-first"), child)); + await vi.waitFor(() => expect(withdrawn).toBe(true)); + await flush(h); + // A late answer cannot revive the ended child turn. + answer(); + expect(await permission).toMatchObject({decision: "cancel"}); + await snapshot(h, `standard-subagents-v${version}-permission${replay ? "-replay" : ""}`); + }); + + it("rejects obsolete approval replies even while terminal output is backpressured", async () => { + let answer!: () => void; + const h = await connect({ + onRequestPermission: request => new Promise(resolve => { + answer = () => resolve({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}); + }), + }); + await delegate(h); + const approval = h.triggerApproval("item/commandExecution/requestApproval", { + threadId: child, turnId: "child-first", itemId: "child-command", + command: "echo hello", cwd, commandActions: [], reason: null, availableDecisions: ["accept", "decline"], + }); + await vi.waitFor(() => expect(h.transcript.some(entry => "permissionRequest" in entry)).toBe(true)); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + h.agent["codexAcpClient"]["enqueueSessionNotification"](sessionId, () => held); + try { + h.emit(onThread(turnCompleted("child-first"), child)); + answer(); + expect(await approval).toEqual({decision: "cancel"}); + } finally { + release(); + } + await flush(h); + }); + + it("routes form elicitation to the child without reviving work when the answer arrives", async () => { + const h = await connect({ + v1ClientCapabilities: {...capabilities, elicitation: {form: {}}}, + clientCapabilities: {elicitation: {form: {}}}, + onElicitation: async () => ({action: "decline"}), + }); + await delegate(h); + const response = await h.triggerApproval("item/tool/requestUserInput", { + threadId: child, turnId: "child-first", itemId: "child-question", autoResolutionMs: null, + questions: [{id: "question", header: "Question", question: "Choose", isOther: false, isSecret: false, + options: [{label: "A", description: "Pick A"}]}], + }); + expect(response).toEqual({answers: {}}); + expect(h.transcript.some(entry => "elicitationRequest" in entry + && "sessionId" in entry.elicitationRequest && entry.elicitationRequest.sessionId === childId)).toBe(true); + h.emit(onThread(turnCompleted("child-first"), child)); + await snapshot(h, `standard-subagents-v${version}-elicitation`); + }); + + it("keeps child plans and titles out of the parent's conversation", async () => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + h.emit({method: "thread/name/updated", params: {threadId: child, threadName: "Worker title"}}); + h.emit(onThread({method: "item/plan/delta", params: { + threadId: child, turnId: "child-first", itemId: "child-plan", delta: "A child plan", + }}, child)); + h.emit({method: "item/completed", params: { + threadId: child, turnId: "child-first", completedAtMs: 0, item: {type: "plan", id: "child-plan", text: "A child plan"}, + }}); + h.emit(onThread(turnCompleted("child-first"), child)); + await snapshot(h, `standard-subagents-v${version}-plan-title`); + }); + + it("withdraws independent child permissions when the parent is cancelled", async () => { + let withdrawn = false; + const h = await connect({ + onRequestPermission: (_request, signal) => new Promise(resolve => { + signal.addEventListener("abort", () => { + withdrawn = true; + resolve({outcome: {outcome: "cancelled"}}); + }, {once: true}); + }), + }); + await delegate(h); + const approval = h.triggerApproval("item/commandExecution/requestApproval", { + threadId: child, turnId: "child-first", itemId: "child-command", + command: "echo hello", cwd, commandActions: [], reason: null, availableDecisions: ["accept", "decline"], + }); + await vi.waitFor(() => expect(h.transcript.some(entry => "permissionRequest" in entry)).toBe(true)); + await h.cancel(); + await vi.waitFor(() => expect(withdrawn).toBe(true)); + expect(await approval).toEqual({decision: "cancel"}); + expect(h.turnInterruptCalls()).toContainEqual({threadId: child, turnId: "child-first"}); + }); + + it("ignores late child traffic from a disconnected runtime", async () => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + h.emit(onThread(itemStarted(commandExecutionItem("old-command"), "child-first"), child)); + await flush(h); + await h.agent["sessions"].get(sessionId)!.subagents.finishOutstanding("disconnected"); + h.emit(onThread(turnStarted("stale-turn"), child)); + h.emit(onThread(agentMessageDelta("Stale output", "stale-turn", "stale-message"), child)); + h.emit(onThread(turnCompleted("child-first"), child)); + await snapshot(h, `standard-subagents-v${version}-disconnected`); + expect(childUpdates(h).some(({update}) => hasText(update, "Stale output"))).toBe(false); + }); + + it("cascades parent cancellation without declaring an unobserved child outcome", async () => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + await flush(h); + await h.cancel(); + await vi.waitFor(() => expect(h.turnInterruptCalls()).toContainEqual({threadId: child, turnId: "child-first"})); + await flush(h); + expect(childUpdates(h).filter(({update}) => update.sessionUpdate === "subagent_update" && "state" in update)).toHaveLength(1); + h.emit(onThread({method: "turn/completed", params: {threadId: child, turn: createTurn("interrupted", "child-first")}}, child)); + await snapshot(h, `standard-subagents-v${version}-cancel`); + }); + + it("uses unknown on lost observability, not a fabricated failure or permanent closing", async () => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + h.emit({method: "thread/status/changed", params: {threadId: child, status: {type: "notLoaded"}}}); + await snapshot(h, `standard-subagents-v${version}-unknown`); + }); + + it("replays stable child identities and leaves historical work unconfirmed", async () => { + const h = await connect(); + const rootItems: ThreadItem[] = [ + {type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}, + {type: "subAgentActivity", id: "completed", kind: "completed", agentThreadId: child, agentPath: "/root/worker"}, + ]; + const reply: ThreadItem = {type: "agentMessage", id: "persisted-reply", text: "Historical result", phase: null, + memoryCitation: null, delivery: null, questions: null}; + history(h, rootItems, [[userMessageItem(null, "Delegated task"), reply], [{...reply, id: "second-reply", text: "Historical follow-up"}]]); + await h.request(version === 1 ? "session/load" : "session/resume", { + sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + await snapshot(h, `standard-subagents-v${version}-replay`); + expect(childUpdates(h).some(({update}) => update.sessionUpdate === "subagent_update" && "state" in update)).toBe(false); + }); + + it("deduplicates replay overlap and publishes only the latest live work snapshot after the response", async () => { + const h = await connect(); + const rootItems: ThreadItem[] = [{type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}]; + const reply: ThreadItem = {type: "agentMessage", id: "persisted-reply", text: "Historical result", phase: null, + memoryCitation: null, delivery: null, questions: null}; + history(h, rootItems, [[reply]], () => { + h.emit(activity()); + h.emit(onThread(turnStarted("child-overlap"), child)); + h.emit(onThread(agentMessageDelta("Historical result", "child-overlap", reply.id), child)); + h.emit(onThread(agentMessageDelta("New result", "child-overlap", "live-reply"), child)); + h.emit(onThread(turnCompleted("child-overlap"), child)); + }); + await h.request(version === 1 ? "session/load" : "session/resume", { + sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + const boundary = childUpdates(h).length; + await vi.waitFor(() => expect(childUpdates(h).some(({update}) => + hasText(update, "New result"))).toBe(true)); + await flush(h); + const entries = childUpdates(h); + expect(entries.filter(({update}) => hasText(update, "Historical result"))).toHaveLength(1); + await expect(JSON.stringify([ + ...entries.slice(0, boundary), {response: "replay complete"}, ...entries.slice(boundary), + ], null, 2)).toMatchFileSnapshot(`data/standard-subagents-v${version}-replay-live.json`); + }); + + it("remaps copied child identities when a fork's history is loaded", async () => { + const h = await connect(); + const rootItems: ThreadItem[] = [{type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}]; + history(h, rootItems, [[]]); + h.setCodexResponse("thread/fork", async () => ({ + ...codexResponse("thread/start") as object, thread: {...createThread(), id: "fork", sessionId: "fork", forkedFromId: sessionId}, + })); + const load = (id: string) => h.request(version === 1 ? "session/load" : "session/resume", { + sessionId: id, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + await load(sessionId); + const fork = await h.request("session/fork", {sessionId, cwd, mcpServers: []}); + await load(fork.sessionId); + await snapshot(h, `standard-subagents-v${version}-fork`); + const roster = childUpdates(h).filter(({update}) => update.sessionUpdate === "subagent_update"); + expect(roster.map(({sessionId}) => sessionId)).toEqual([sessionId, "fork"]); + }); + + it("serializes same-parent replay through the preceding response and live-traffic boundary", async () => { + const h = await connect(); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let reads = 0; + let firstResponded = false; + history(h, [{type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}], [[]], async () => { + reads++; + if (reads === 1) await held; + else expect(firstResponded).toBe(true); + }); + const params = {sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {})}; + const method = version === 1 ? "session/load" : "session/resume"; + const first = h.request(method, params).then(() => { firstResponded = true; }); + await vi.waitFor(() => expect(reads).toBe(1)); + const second = h.request(method, params); + await h.request("session/list", {}); + expect(reads).toBe(1); + h.emit(activity()); + h.emit(onThread(turnStarted("child-overlap"), child)); + h.emit(onThread(agentMessageDelta("Live result", "child-overlap", "overlap-message"), child)); + h.emit(onThread(turnCompleted("child-overlap"), child)); + release(); + await first; + await second; + await flush(h); + expect(reads).toBe(2); + expect(childUpdates(h).filter(({update}) => hasText(update, "Live result"))).toHaveLength(1); + }); + + it("does not let a queued setup reopen a parent closed while replay was in flight", async () => { + const h = await connect(); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let reads = 0; + history(h, [{type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}], [[]], async () => { + reads++; + await held; + }); + const params = {sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {})}; + const method = version === 1 ? "session/load" : "session/resume"; + const first = h.request(method, params); + first.catch(() => {}); + await vi.waitFor(() => expect(reads).toBe(1)); + const second = h.request(method, params); + second.catch(() => {}); + await h.request("session/list", {}); + await h.request("session/close", {sessionId}); + release(); + await expect(first).rejects.toMatchObject({code: -32600}); + await expect(second).rejects.toMatchObject({code: -32600}); + expect(reads).toBe(1); + }); +}); diff --git a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts index b2461025b..c3b90cda5 100644 --- a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts +++ b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts @@ -84,6 +84,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 {}; } @@ -104,6 +106,7 @@ export type TranscriptEntry = export type PromptSession = { connection: {close(): void}; transcript: TranscriptEntry[]; + scopedUpdates: Array<{sessionId: string; update: acpV2.SessionUpdate | acp.SessionUpdate}>; /** Updates published while the session was being set up, before `transcript` starts recording. */ setupUpdates: acpV2.SessionUpdate[]; turnStartParams: Array>; @@ -130,6 +133,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. */ @@ -138,11 +142,12 @@ 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. */ + /** Answers `session/request_permission`. Required by tests that trigger one. */ onRequestPermission?: (request: acpV2.RequestPermissionRequest, signal: AbortSignal) => acpV2.RequestPermissionResponse | Promise, - /** Answers `elicitation/create` (v2 only). Required by tests that trigger one. */ + /** Answers `elicitation/create`. Required by tests that trigger one. */ onElicitation?: (request: acpV2.CreateElicitationRequest, signal: AbortSignal) => acpV2.CreateElicitationResponse | Promise, /** * Overrides canned Codex responses from the start, unlike `setCodexResponse` (only available @@ -153,6 +158,7 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { } = {}): Promise { const mocks = createMockConnections(); const transcript: TranscriptEntry[] = []; + const scopedUpdates: PromptSession["scopedUpdates"] = []; const turnStartParams: Array> = []; const codexResponseOverrides = new Map Promise>( Object.entries(options.codexResponses ?? {}), @@ -195,11 +201,12 @@ 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); @@ -211,7 +218,7 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { let notifyCancel: () => Promise; 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) { @@ -245,9 +252,24 @@ 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)) + .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; + }) .connect(clientStream); - await v1Connection.agent.request(acp.methods.agent.initialize, {protocolVersion: acp.PROTOCOL_VERSION}); + 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 +309,7 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { return { connection, transcript, + scopedUpdates, setupUpdates, turnStartParams, setTurnStart: (handler: typeof turnStart) => { @@ -315,6 +338,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), }; } diff --git a/src/__tests__/data/foreground-v2-unknown-late-answer.json b/src/__tests__/data/foreground-v2-unknown-late-answer.json new file mode 100644 index 000000000..2168fb43b --- /dev/null +++ b/src/__tests__/data/foreground-v2-unknown-late-answer.json @@ -0,0 +1,17 @@ +[ + { + "state": "running" + }, + { + "state": "requires_action" + }, + { + "state": "requires_action" + }, + { + "state": "unknown" + }, + { + "state": "unknown" + } +] \ No newline at end of file diff --git a/src/__tests__/integration/mock-responses-server.ts b/src/__tests__/integration/mock-responses-server.ts index cfc2a960a..7f48b73ae 100644 --- a/src/__tests__/integration/mock-responses-server.ts +++ b/src/__tests__/integration/mock-responses-server.ts @@ -18,6 +18,8 @@ export interface MockResponsesServer { enqueue(...responses: string[]): void; /** Keeps returning the HTTP error across runtime retries, until cleared with null. */ setErrorResponse(response: MockErrorResponse | null): void; + /** Selects replies by request instead of arrival order, for concurrent root/child/title calls. */ + setResponseHandler(handler: (request: unknown) => string | undefined): void; close(): Promise; } @@ -33,6 +35,7 @@ function parseBody(body: string): unknown { export async function startMockResponsesServer(): Promise { const queue: string[] = []; let errorResponse: MockErrorResponse | null = null; + let responseHandler: ((request: unknown) => string | undefined) | undefined; const requests: unknown[] = []; let unexpectedRequests = 0; let requestCount = 0; @@ -42,9 +45,10 @@ export async function startMockResponsesServer(): Promise { req.on("data", (chunk) => body += chunk); req.on("end", () => { requestCount++; - requests.push(parseBody(body)); + const parsed = parseBody(body); + requests.push(parsed); const isResponsesCall = req.method === "POST" && (req.url ?? "").split("?")[0]!.endsWith("/responses"); - const next = isResponsesCall ? errorResponse ?? queue.shift() : undefined; + const next = isResponsesCall ? errorResponse ?? (responseHandler ? responseHandler(parsed) : queue.shift()) : undefined; if (next === undefined) { unexpectedRequests++; res.writeHead(isResponsesCall ? 400 : 404); @@ -76,6 +80,7 @@ export async function startMockResponsesServer(): Promise { queue.push(...responses); }, setErrorResponse: response => { errorResponse = response; }, + setResponseHandler: handler => { responseHandler = handler; }, close: async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); @@ -121,8 +126,13 @@ export function textResponse(...chunks: string[]): string { /** The model calls the shell tool `exec_command` with `args` (e.g. `{cmd: "echo hi"}`). */ export function execCommandResponse(callId: string, args: object): string { + return functionCallResponse(callId, "exec_command", args); +} + +export function functionCallResponse(callId: string, name: string, args: object, namespace?: string): string { return wrap(nextId(), [ - {type: "response.output_item.done", output_index: 0, item: {type: "function_call", id: `fc_${callId}`, call_id: callId, name: "exec_command", arguments: JSON.stringify(args)}}, + {type: "response.output_item.done", output_index: 0, item: {type: "function_call", id: `fc_${callId}`, call_id: callId, name, + ...(namespace ? {namespace} : {}), arguments: JSON.stringify(args)}}, ]); } diff --git a/src/__tests__/integration/standard-subagents.test.ts b/src/__tests__/integration/standard-subagents.test.ts new file mode 100644 index 000000000..c478b6f87 --- /dev/null +++ b/src/__tests__/integration/standard-subagents.test.ts @@ -0,0 +1,105 @@ +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} from "node:fs"; +import {join} from "node:path"; +import {Readable, Writable} from "node:stream"; +import {expect, it, vi} from "vitest"; +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: standard subagent sessions", () => { + it.each([1, 2] as const)("announces and routes real Codex children through ACP v%s", async version => { + const mock = await startMockResponsesServer(); + let delegated = false; + mock.setResponseHandler(request => { + const model = (request as {model?: string}).model; + if (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-subagents-")); + 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 child = spawn(process.execPath, ["--import", "tsx", "src/index.ts"], { + cwd: process.cwd(), env: {...process.env, CODEX_HOME: codexHome}, stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", chunk => { stderr += chunk.toString(); }); + const updates: Array<{sessionId: string; update: any}> = []; + const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.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; + }; + 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: "subagent-integration", 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"}]}); + prompt.catch(() => {}); + await vi.waitFor(() => { + const child = updates.find(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "subagent_update"); + const requests = mock.requests.map(request => { + const body = request as {model?: string; tools?: Array<{name?: string; type?: string}>; input?: Array<{type?: string}>}; + return {model: body.model, tools: body.tools?.map(tool => tool.name ?? tool.type), + outputs: body.input?.filter(item => item.type === "function_call_output")}; + }); + expect(child, `${stderr}\n${JSON.stringify({updates, requests})}`).toBeDefined(); + const id = child!.update.sessionId; + const announcement = updates.indexOf(child!); + const message = updates.findIndex(entry => entry.sessionId === id + && entry.update.sessionUpdate === "agent_message_chunk" && entry.update.content.text.includes("CHILD_RESULT")); + expect(message, `${stderr}\n${JSON.stringify(updates)}`).toBeGreaterThan(announcement); + expect(updates.some(entry => entry.update.sessionUpdate === "subagent_update" + && entry.update.sessionId === id && entry.update.state?.state === "idle")).toBe(true); + }, {timeout: 15_000}); + await prompt; + expect(updates.some(entry => ["subagent_spawned", "subagent_state_update", "_subagent_update"].includes(entry.update.sessionUpdate))).toBe(false); + expect(mock.unexpectedRequests).toBe(0); + } finally { + connection.close(); + child.kill("SIGTERM"); + if (child.exitCode === null) await once(child, "exit"); + await mock.close(); + await removeDirectoryWithRetry(root); + } + }); +}); diff --git a/src/permissions/CodexApprovalHandler.ts b/src/permissions/CodexApprovalHandler.ts index a5e1af4bc..c8b28d808 100644 --- a/src/permissions/CodexApprovalHandler.ts +++ b/src/permissions/CodexApprovalHandler.ts @@ -32,6 +32,7 @@ import {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; import {CommandReporter} from "../tool-calls/reporters/CommandReporter"; import {FileChangeReporter} from "../tool-calls/reporters/FileChangeReporter"; import {SandboxPermissionReporter} from "../tool-calls/reporters/SandboxPermissionReporter"; +import {subagentInteractionSignal, withSubagentUserAction} from "../subagents/SubagentInteraction"; export class CodexApprovalHandler implements ApprovalHandler { constructor( @@ -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 b0c27a7cd..30019e2f2 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 existing private contract, retained independently of standard subagent sessions. */ export type SubagentSessionCapabilities = { cancel?: boolean; close?: boolean; @@ -36,15 +38,26 @@ export type SubagentAwareSessionCapabilities = SessionCapabilities & { subagents?: Record; }; -export function clientSupportsSubagents( +export type SubagentProtocol = "hidden" | "air" | "standard"; + +/** Standard snapshots share their wire shape across versions; controls remain deliberately empty. */ +export type StandardSubagentUpdate = Omit & { + sessionUpdate: "subagent_update"; + capabilities?: Record; +}; + +export function subagentProtocol( capabilities?: ClientCapabilities | null, -): boolean { + version: 1 | 2 = 1, +): SubagentProtocol { 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"; + // AIR keeps its existing opt-in and fallback until its client migrates to the standard contract. + if (air) return "hidden"; + return version === 2 || standard ? "standard" : "hidden"; } diff --git a/src/subagents/CodexSubagentSubscriptions.ts b/src/subagents/CodexSubagentSubscriptions.ts index 27998e086..bd1ae1739 100644 --- a/src/subagents/CodexSubagentSubscriptions.ts +++ b/src/subagents/CodexSubagentSubscriptions.ts @@ -6,6 +6,10 @@ import type { import type {ServerNotification} from "../app-server"; 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 +19,8 @@ type Subscription = { elicitationHandler: ElicitationHandler; waitForRootNotifications(): Promise; waitForChildSession(childThreadId: string): Promise; + withUserAction?: SubagentInteraction; + observeWorkEnd?: (event: ServerNotification) => void; }; type SessionSubscription = { @@ -41,6 +47,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 +60,12 @@ export class CodexSubagentSubscriptions { this.sessions.delete(rootSessionId); } + redispatch(rootSessionId: string, notifications: ServerNotification[]): void { + const session = this.sessions.get(rootSessionId); + if (!session) return; + for (const notification of notifications) session.current.dispatch(notification); + } + private discover(session: SessionSubscription, event: ServerNotification): void { if (event.method !== "item/started" && event.method !== "item/completed") { return; @@ -75,6 +88,7 @@ export class CodexSubagentSubscriptions { const eventThreadId = (childEvent.params as {threadId?: unknown}).threadId; if (eventThreadId !== childSessionId) return; this.discover(session, childEvent); + session.current.observeWorkEnd?.(childEvent); if (session.current.supportsSubagents) session.current.dispatch(childEvent); else session.current.enqueueInteraction(this.rootAttributed(childEvent, session.current.rootSessionId)); }); @@ -90,27 +104,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 +130,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 000000000..3d45e18e9 --- /dev/null +++ b/src/subagents/StandardSubagentEventRouter.ts @@ -0,0 +1,498 @@ +import type {ServerNotification} from "../app-server"; +import type {StateUpdate} from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type {ThreadItem} from "../app-server/v2"; +import {ACPSessionConnection} from "../ACPSessionConnection"; +import {AcpV2ForegroundState} from "../AcpV2ForegroundState"; +import {CodexPlanStream} from "../CodexPlanStream"; +import {CodexSessionToolCalls} from "../CodexSessionToolCalls"; +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 type {ClosingChildSession} 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; + foreground: AcpV2ForegroundState; + toolCalls: CodexSessionToolCalls; + requests: AbortController; + workRequests: Map; + closedRequestTurns: Set; + endedTurns: Set; + live: boolean; +}; +type Pending = { + parentThreadId?: string; + path?: string; + description?: string; + notifications: PendingNotificationBuffer; +}; + +/** A child is a reusable conversation; work endings never terminate or rename its association. */ +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 replayedItems = new Map>(); + private retired = false; + private snapshotting = false; + private readonly snapshots = new Map(); + + 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(), + ) { + this.deferredLive = new PendingNotificationBuffer(rootSessionId); + } + + async handle(notification: ServerNotification): Promise { + 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")); + } + const params = notification.params as {itemId?: string; item?: {id: string}}; + const itemId = params.itemId ?? params.item?.id; + if (itemId !== undefined && this.replayedItems.get(threadId)?.has(itemId)) return true; + if (threadId !== this.rootSessionId && this.deferLive) { + this.deferredLive.push(notification); + return true; + } + if (threadId !== this.rootSessionId && !this.children.has(threadId)) { + const pending = this.pendingFor(threadId); + pending.notifications.push(notification); + if (await this.restoreKnown(threadId)) { + this.pending.delete(threadId); + for (const event of pending.notifications.take()) this.replayQueue.push(event); + } + return true; + } + if (notification.method === "item/started" || notification.method === "item/completed") { + const item = notification.params.item; + if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") { + for (const id of item.receiverThreadIds) { + if (!id.trim() || id === item.senderThreadId || id === this.rootSessionId) continue; + if (this.children.has(id)) continue; + const pending = this.pendingFor(id); + // The sender, not whichever stream carried the callback, establishes parentage. + pending.parentThreadId = item.senderThreadId; + if (item.prompt) pending.description = item.prompt; + } + await this.materializePending(); + // A started spawn can have no receiver yet; never leave an orphan legacy tool call. + return true; + } + if (item.type === "subAgentActivity") { + if (isRootAgentPath(item.agentPath)) return true; + const child = this.children.get(item.agentThreadId); + if (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.materializePending(); + return true; + } + } + const child = this.children.get(threadId); + if (!child) return false; + await this.ensureAnnounced(child); + if (this.retired) return true; + child.live = true; + const progressTurnId = (notification.params as {turnId?: string}).turnId; + const modelProgress = notification.method === "item/agentMessage/delta" + || notification.method === "item/plan/delta" + || notification.method === "item/reasoning/textDelta" + || notification.method === "item/reasoning/summaryTextDelta"; + if (progressTurnId !== undefined && modelProgress && !child.endedTurns.has(progressTurnId) + && (child.turnId === null || child.turnId === progressTurnId)) { + if (child.turnId === null) { + child.turnId = progressTurnId; + child.foreground.invalidate(); + } + await child.foreground.update({state: "running"}); + } + switch (notification.method) { + case "turn/started": + if (child.endedTurns.has(notification.params.turn.id)) return true; + if (child.turnId !== notification.params.turn.id) { + if (child.turnId !== null) this.endWorkRequests(child, child.turnId); + child.foreground.invalidate(); + } + child.turnId = notification.params.turn.id; + await child.foreground.start(); + 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": + // Runtime configuration and retry incidents of a child must not mutate its parent. + return true; + case "turn/completed": { + const turn = notification.params.turn; + if (turn.status === "inProgress") return true; + this.endWorkRequests(child, turn.id); + child.endedTurns.add(turn.id); + if (child.turnId !== null && child.turnId !== 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.turnId = 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 (child.turnId !== null && notification.params.item.type !== "userMessage") { + await child.foreground.startItem(notification.params.item.id); + } + if (notification.params.item.type === "plan") return true; + break; + case "item/completed": { + await child.foreground.completeItem(notification.params.item.id); + const item = notification.params.item; + if (item.type === "plan") { + // Completed snapshots avoid replaying a partial draft over newer history. + 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) { + if (pending.parentThreadId === this.rootSessionId) return this.rootSessionId; + return 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); + for (const event of pending.notifications.take()) this.replayQueue.push(event); + 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) return; + if (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); + } + + 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 title = path === undefined ? undefined : nameFromAgentPath(path, "Agent"); + const child: Child = { + ...association, turnId: null, + ...(path !== undefined ? {path: normalizeAgentPath(path)} : {}), + ...(title !== undefined ? {title} : {}), + ...(description !== undefined ? {description} : {}), + foreground: new AcpV2ForegroundState(async state => { + if (this.retired && state.state !== "unknown") 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(), + 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; + } + + /** Historical announcements provide routing, never a claim about current work. */ + async announceHistory(threadId: string, path: string): Promise { + if (threadId === this.rootSessionId) return null; + const existing = this.children.get(threadId); + if (existing) { + await this.ensureAnnounced(existing); + existing.path = normalizeAgentPath(path); + const title = nameFromAgentPath(path, "Agent"); + if (title !== existing.title) { + existing.title = title; + await this.session.update({sessionUpdate: "subagent_update", sessionId: existing.sessionId, title}, existing.parentSessionId); + } + return existing.sessionId; + } + const pending: Pending = {path: normalizeAgentPath(path), notifications: new PendingNotificationBuffer(threadId)}; + const parent = this.parentFor(pending); + return parent === undefined ? null : this.announce(threadId, parent, path); + } + + releaseReplay(): ServerNotification[] { + this.snapshotting = this.deferLive; + this.deferLive = false; + return this.deferredLive.take(); + } + + async publishReplaySnapshots(): Promise { + this.snapshotting = false; + const snapshots = [...this.snapshots.values()]; + this.snapshots.clear(); + if (this.retired) return; + for (const {child, state} of snapshots) { + await this.ensureAnnounced(child); + await this.session.updateSubagentState(child.sessionId, child.parentSessionId, state); + } + } + + 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 foreground = !this.deferLive && !this.retired && child.turnId !== null && child.turnId === turnId; + try { + return await runSubagentInteraction({ + sessionId, signal, + nextToolCallId: serverName => this.associations.nextToolCallId(sessionId, serverName), + 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); + } + + /** Withdraw requests at receipt time, before output backpressure can delay terminal rendering. */ + observeWorkEnd(notification: ServerNotification): void { + if (this.retired || notification.method !== "turn/completed" || notification.params.turn.status === "inProgress") return; + 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 { + 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; + } + + async finishOutstanding(state: SubagentState): Promise { + // A parent's failure/cancellation alone is not evidence that its children stopped. + 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})); + } + + isRetired(): boolean { + return this.retired; + } + + openToolCalls(threadId: string): CodexSessionToolCalls | undefined { + return this.children.get(threadId)?.toolCalls; + } + + rememberReplayedItem(threadId: string, itemId: string): void { + const items = this.replayedItems.get(threadId) ?? new Set(); + items.add(itemId); + this.replayedItems.set(threadId, items); + } + + 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.turnId !== null && child.turnId !== turn.id)) return []; + return [{threadId: child.threadId, sessionId: child.sessionId, + state: turn.status === "interrupted" ? "cancelled" : turn.status === "failed" ? "failed" : "completed"}]; + } + // A child's independent work must not prolong its parent's completed foreground turn. + 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 000000000..bc55e597c --- /dev/null +++ b/src/subagents/SubagentAssociations.ts @@ -0,0 +1,60 @@ +import {RequestError} from "@agentclientprotocol/sdk/unstable"; + +export type SubagentAssociation = { + rootSessionId: string; + parentSessionId: string; + threadId: string; + sessionId: string; +}; + +/** Connection-owned identities and restrictions survive idle work, handler replacement, and replay. */ +export class SubagentAssociations { + private readonly associations = new Map(); + private readonly childThreads = new Set(); + private readonly interactionSequences = new Map(); + + sessionId(rootSessionId: string, threadId: string): string { + // A fork has its own tree, even when its copied history references the original Codex child. + 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 000000000..179602c90 --- /dev/null +++ b/src/subagents/SubagentEventRouter.ts @@ -0,0 +1,6 @@ +import type {CodexSubagentEventRouter} from "./CodexSubagentEventRouter"; + +/** Shared event/reporting surface; AIR and standard sessions have separate identity and lifecycle 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 000000000..f48790f18 --- /dev/null +++ b/src/subagents/SubagentInteraction.ts @@ -0,0 +1,61 @@ +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; +}; + +// Each concurrent backend request keeps its own child/work scope through async permission rendering. +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; +} + +/** Track only requests actually sent to the client, not unsupported/rejected backend callbacks. */ +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/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..4d54f7bcd --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import {configDefaults, defineConfig} from "vitest/config"; + +export default defineConfig({ + test: { + // Codex fixtures can download plugins with their own tests outside these owned directories. + include: configDefaults.include.flatMap(pattern => [ + `src/__tests__/${pattern}`, + `scripts/${pattern}`, + ]), + }, +}); From 8119d4afca2e655b0170b139507acf01d1f03367 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 21 Oct 2026 10:15:25 +0200 Subject: [PATCH 2/5] fix: preserve subagent interactions and replay ordering --- readme-dev.md | 6 +- src/AcpAgentRouter.ts | 59 ++++-- src/AcpResponseDelivery.ts | 56 ++++++ src/CodexAcpServer.ts | 87 ++++++--- src/__tests__/AcpResponseDelivery.test.ts | 93 +++++++++ .../CodexACPAgent/standard-subagents.test.ts | 184 +++++++++++++++++- .../CodexACPAgent/v2-prompt-harness.ts | 29 ++- .../integration/mock-responses-server.ts | 17 +- .../integration/standard-subagents.test.ts | 56 +++++- src/subagents/StandardSubagentEventRouter.ts | 7 + src/subagents/SubagentInteractionHandlers.ts | 32 +++ vitest.config.ts => vitest.config.mjs | 0 12 files changed, 574 insertions(+), 52 deletions(-) create mode 100644 src/AcpResponseDelivery.ts create mode 100644 src/__tests__/AcpResponseDelivery.test.ts create mode 100644 src/subagents/SubagentInteractionHandlers.ts rename vitest.config.ts => vitest.config.mjs (100%) diff --git a/readme-dev.md b/readme-dev.md index 3d1384d96..75609513b 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -209,8 +209,10 @@ Responding to child-scoped Agent-to-Client requests is still allowed. Replayed a transcripts do not grant controls or confirm current activity; overlapping completed items are deduplicated and fresh work snapshots follow the replay response. A copied history alone never authorizes interrupting the original child runtime. -Load/resume requests for the same standard parent are serialized through this response boundary; -close and cancel remain independent, and a close invalidates already queued setups. +Load/resume attachment operations for the same standard parent are serialized. Fresh child +traffic follows the actual response write, including when the response is part of a batch; +overlapping attachments publish after the latest setup's response rather than interleaving +with its replay. Close and cancel remain independent, and a close invalidates queued setups. Dedicated `session_message` reporting and optional child controls remain follow-ups. Ordinary runtime tool reports remain available for operations such as waiting and follow-up tasks. diff --git a/src/AcpAgentRouter.ts b/src/AcpAgentRouter.ts index b2052bed0..c2295c6fc 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,6 +66,20 @@ 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 = (params?: {sessionId?: unknown}): CodexAcpServer => { @@ -74,34 +89,52 @@ export function createAcpAgentRouter( if (typeof params?.sessionId === "string") codexAcpServer.assertSessionMutationAllowed(params.sessionId); return codexAcpServer; }; - const setupTails = new Map>(); - const setup = (sessionId: string, operation: () => Promise, signal: AbortSignal): Promise => { - const agent = getAgent(); + 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; }); - // A following setup must not consume the previous setup's deferred child traffic. - const task = (setupTails.get(sessionId) ?? Promise.resolve()).catch(() => {}).then(async () => { + const task = state.tail.catch(() => {}).then(async () => { + let registered = false; try { + // A publication may start while this task is yielding to an earlier resolved promise. + 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(); } - await agent.afterSessionSetupResponse(sessionId); }); - setupTails.set(sessionId, task); - void task.finally(() => { - if (setupTails.get(sessionId) === task) setupTails.delete(sessionId); - }).catch(() => {}); + // Do not wait for delivery here: two requests in one batch share a response write. + state.tail = task; return response; }; const attachAgent = (agent: CodexAcpServer, signal: AbortSignal) => { codexAcpServer = agent; signal.addEventListener("abort", () => { + setups.clear(); agent.sessionIndex.dispose(); if (codexAcpServer === agent) { codexAcpServer = null; @@ -113,11 +146,11 @@ 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) => setup(ctx.params.sessionId, () => getAgent(ctx.params).loadSession(ctx.params), ctx.signal)) + .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(ctx.params).deleteSession(ctx.params)) - .onRequest(acp.methods.agent.session.resume, (ctx) => setup(ctx.params.sessionId, () => getAgent(ctx.params).resumeSession(ctx.params), ctx.signal)) + .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)) @@ -149,7 +182,7 @@ export function createAcpAgentRouter( .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(ctx.params).deleteSession(ctx.params)) - .onRequest(acpV2.methods.agent.session.resume, (ctx) => setup(ctx.params.sessionId, () => getAgent(ctx.params).resumeSessionV2(ctx.params), ctx.signal)) + .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. diff --git a/src/AcpResponseDelivery.ts b/src/AcpResponseDelivery.ts new file mode 100644 index 000000000..022e3d0f3 --- /dev/null +++ b/src/AcpResponseDelivery.ts @@ -0,0 +1,56 @@ +import type {AnyWireMessage, JsonRpcId, WireStream} from "@agentclientprotocol/sdk/experimental/v2/unstable"; + +/** Observes completed wire writes without changing SDK messages or batch 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/CodexAcpServer.ts b/src/CodexAcpServer.ts index 8e5d5ab2e..bbe4a1dd9 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -173,6 +173,7 @@ import { import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; import {StandardSubagentEventRouter} from "./subagents/StandardSubagentEventRouter"; import {SubagentAssociations} from "./subagents/SubagentAssociations"; +import {SubagentInteractionHandlers} from "./subagents/SubagentInteractionHandlers"; import type {SubagentEventRouter} from "./subagents/SubagentEventRouter"; import {isRootAgentPath, nameFromAgentPath} from "./subagents/CodexAgentPath"; import {listCodexHooks, trustCodexHooks, type CodexHookIdentity} from "./CodexHookTrust"; @@ -504,6 +505,7 @@ export class CodexAcpServer { 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; @@ -646,10 +648,47 @@ export class CodexAcpServer { } const session = new ACPSessionConnection(this.connection, sessionId); const release = (childId: string) => this.reportingConnection.reports.releaseOpen(childId); - return this.usesStandardSubagents() + const router = this.usesStandardSubagents() ? new StandardSubagentEventRouter(sessionId, session, this.subagentAssociations, release, this.capabilities, replay, this.sessions.get(sessionId)?.subagentTransition) : new CodexSubagentEventRouter(sessionId, this.supportsNativeSubagents(), session, release); + 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, sessionId) => handlers.observe(event, sessionId)); + 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), + }; } /** Called at the router boundary, never for the parent's internal runtime operations. */ @@ -672,18 +711,14 @@ export class CodexAcpServer { } afterSessionSetupResponse(sessionId: string): Promise { - // Resolve the RPC first; release traffic on the next event-loop turn, then unblock the next setup. - return new Promise(resolve => setImmediate(() => { - const router = this.sessions.get(sessionId)?.subagents; - if (!(router instanceof StandardSubagentEventRouter) || this.sessionIsClosing(sessionId)) return resolve(); - this.codexAcpClient.redispatchSessionNotifications( - sessionId, router.releaseReplay(), () => router.publishReplaySnapshots(), - ); - void this.codexAcpClient.waitForSessionNotifications(sessionId).then(resolve, error => { - logger.error("Failed to release child traffic after session setup", error); - resolve(); - }); - })); + 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 { @@ -1545,11 +1580,15 @@ export class CodexAcpServer { await baselineEventHandler.handleSessionScopedNotification(event); await this.trackCodexTurnCompletion(sessionState, event, baselineEventHandler); }, - DENY_ALL_APPROVALS, - DENY_ALL_ELICITATIONS, + 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(); @@ -4693,13 +4732,11 @@ export class CodexAcpServer { ); promptElicitations = elicitationHandler; const observeInteraction = async (event: ServerNotification): Promise => { - const scoped = sessionState.subagents instanceof StandardSubagentEventRouter - && typeof (event.params as {threadId?: unknown}).threadId === "string" - && sessionState.subagents.isNativeSubagentThread((event.params as {threadId: string}).threadId) - ? {...event, params: {...event.params, threadId: sessionState.subagents.notificationSessionId(event)}} as ServerNotification - : event; - permissionContext.handleNotification(scoped); - await elicitationHandler.handleNotification(scoped); + 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); }; const resolvePendingInsertion = async (): Promise => { if (pendingInsertion === undefined) { @@ -4746,8 +4783,8 @@ export class CodexAcpServer { } await this.trackCodexTurnCompletion(sessionState, event, promptEventHandler); }, - approvalHandler, - elicitationHandler, + this.routeChildApprovals(approvalHandler), + this.routeChildElicitations(elicitationHandler), this.supportsNativeSubagents(), observeInteraction, childThreadId => promptEventHandler.waitForNativeSubagentSession(childThreadId), diff --git a/src/__tests__/AcpResponseDelivery.test.ts b/src/__tests__/AcpResponseDelivery.test.ts new file mode 100644 index 000000000..1f6b71c72 --- /dev/null +++ b/src/__tests__/AcpResponseDelivery.test.ts @@ -0,0 +1,93 @@ +import {describe, expect, it, vi} from "vitest"; +import type {AnyWireMessage, WireStream} from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import {AcpResponseDelivery} from "../AcpResponseDelivery"; + +describe("ACP response delivery", () => { + it("waits for a complete batch write, preserving framing and request-id types", async () => { + const delivered: Array = []; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const messages: AnyWireMessage[] = []; + const stream: WireStream = { + readable: new ReadableStream(), + writable: new WritableStream({ + async write(message) { + messages.push(message); + await held; + }, + }), + }; + const delivery = new AcpResponseDelivery(); + delivery.afterWrite(0, () => { delivered.push(0); }); + delivery.afterWrite("0", () => { delivered.push("0"); }); + const observed = delivery.observe(stream); + expect(observed.readable).toBe(stream.readable); + const writer = observed.writable.getWriter(); + const batch: AnyWireMessage = [ + {jsonrpc: "2.0", id: 0, result: {}}, + {jsonrpc: "2.0", id: "0", error: {code: -32603, message: "Failed"}}, + ]; + const write = writer.write(batch); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(messages[0]).toBe(batch); + expect(delivered).toEqual([]); + release(); + await write; + expect(delivered).toEqual([0, "0"]); + expect(stream.writable.locked).toBe(false); + await writer.close(); + }); + + it("does not mistake outgoing requests or notifications for responses", async () => { + const callback = vi.fn(); + const delivery = new AcpResponseDelivery(); + delivery.afterWrite(1, callback); + const writer = delivery.observe({ + readable: new ReadableStream(), + writable: new WritableStream(), + }).writable.getWriter(); + await writer.write({jsonrpc: "2.0", id: 1, method: "client/request", params: {}}); + await writer.write({jsonrpc: "2.0", method: "session/update", params: {}}); + expect(callback).not.toHaveBeenCalled(); + await writer.write({jsonrpc: "2.0", id: 1, result: {}}); + await writer.write({jsonrpc: "2.0", id: 1, result: {}}); + expect(callback).toHaveBeenCalledTimes(1); + await writer.close(); + }); + + it("does not publish on a failed write and releases the underlying writer", async () => { + const callback = vi.fn(); + const delivery = new AcpResponseDelivery(); + delivery.afterWrite(1, callback); + const failure = new Error("Output closed"); + const stream: WireStream = { + readable: new ReadableStream(), + writable: new WritableStream({write() { throw failure; }}), + }; + const writer = delivery.observe(stream).writable.getWriter(); + await expect(writer.write({jsonrpc: "2.0", id: 1, result: {}})).rejects.toBe(failure); + delivery.dispose(); + expect(callback).not.toHaveBeenCalled(); + expect(stream.writable.locked).toBe(false); + }); + + it("drops callbacks on disposal and forwards aborts", async () => { + const callback = vi.fn(); + const abort = vi.fn(); + const delivery = new AcpResponseDelivery(); + const writer = delivery.observe({ + readable: new ReadableStream(), + writable: new WritableStream({abort}), + }).writable.getWriter(); + delivery.afterWrite(1, callback); + delivery.dispose(); + delivery.afterWrite(2, callback); + await writer.write([ + {jsonrpc: "2.0", id: 1, result: {}}, + {jsonrpc: "2.0", id: 2, result: {}}, + ]); + expect(callback).not.toHaveBeenCalled(); + await writer.abort("Disconnected"); + expect(abort).toHaveBeenCalledWith("Disconnected"); + }); +}); diff --git a/src/__tests__/CodexACPAgent/standard-subagents.test.ts b/src/__tests__/CodexACPAgent/standard-subagents.test.ts index bb82ba9dc..ff4f2a7f7 100644 --- a/src/__tests__/CodexACPAgent/standard-subagents.test.ts +++ b/src/__tests__/CodexACPAgent/standard-subagents.test.ts @@ -1,5 +1,6 @@ import {afterEach, describe, expect, it, vi} from "vitest"; import type {ClientCapabilities} from "@agentclientprotocol/sdk/unstable"; +import type {AnyWireMessage} from "@agentclientprotocol/sdk/experimental/v2/unstable"; import type {ServerNotification} from "../../app-server"; import type {ThreadItem} from "../../app-server/v2"; import {SubagentAssociations} from "../../subagents/SubagentAssociations"; @@ -44,6 +45,14 @@ function hasText(update: PromptSession["scopedUpdates"][number]["update"], text: && "type" in content && content.type === "text" && "text" in content && content.text === text; } +function childStateFrame(frame: AnyWireMessage): boolean { + return (Array.isArray(frame) ? frame : [frame]).some(entry => { + if (!("method" in entry) || entry.method !== "session/update") return false; + const params = entry.params as {sessionId: string; update: {sessionUpdate: string}}; + return params.sessionId === childId && params.update.sessionUpdate === "state_update"; + }); +} + function history(h: PromptSession, rootItems: ThreadItem[], childTurns: ThreadItem[][], onRead?: () => void | Promise) { h.setCodexResponse("thread/resume", async params => { const id = (params as {threadId: string}).threadId; @@ -116,6 +125,106 @@ describe.each([1, 2] as const)("ACP v%s standard child sessions", version => { await h.promptRunFinished(); } + async function finishTitle(h: PromptSession) { + await vi.waitFor(() => expect(h.appServer["pendingTurnCompletionResolvers"].get(titleThreadId)?.has("title-turn")).toBe(true)); + h.emit(onThread(turnCompleted("title-turn"), titleThreadId)); + } + + it.each(version === 1 ? ["session/load", "session/resume"] : ["session/resume"])( + "keeps new child permissions and form elicitation routable after %s", async method => { + const h = await connect({ + v1ClientCapabilities: {...capabilities, elicitation: {form: {}}}, + clientCapabilities: {elicitation: {form: {}}}, + onRequestPermission: async request => ({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}), + onElicitation: async () => ({action: "decline"}), + }); + await delegate(h); + const approval = (itemId: string) => h.triggerApproval("item/commandExecution/requestApproval", { + threadId: child, turnId: "child-first", itemId, + command: "echo hello", cwd, commandActions: [], reason: null, availableDecisions: ["accept", "decline"], + }); + expect(await approval("before-replay")).toEqual({decision: "accept"}); + await finishTitle(h); + history(h, [], [[]]); + await h.request(method, {sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {})}); + expect(await approval("after-replay")).toEqual({decision: "accept"}); + expect(await h.triggerApproval("item/tool/requestUserInput", { + threadId: child, turnId: "child-first", itemId: "after-replay-question", autoResolutionMs: null, + questions: [{id: "question", header: "Question", question: "Choose", isOther: false, isSecret: false, + options: [{label: "A", description: "Pick A"}]}], + })).toEqual({answers: {}}); + const requests = h.transcript.flatMap(entry => "permissionRequest" in entry ? [entry.permissionRequest.sessionId] + : "elicitationRequest" in entry && "sessionId" in entry.elicitationRequest ? [entry.elicitationRequest.sessionId] : []); + expect(requests).toEqual([childId, childId, childId]); + }, + ); + + it("retains accepted child URL elicitation completions across parent replay and a later prompt", async () => { + const h = await connect({ + v1ClientCapabilities: {...capabilities, elicitation: {url: {}}}, + clientCapabilities: {elicitation: {url: {}}}, + onElicitation: async () => ({action: "accept", content: null}), + }); + await delegate(h); + await finishTitle(h); + history(h, [], [[]]); + for (const transition of ["replay", "prompt"] as const) { + const elicitationId = `child-url-${transition}`; + expect(await h.triggerApproval("mcpServer/elicitation/request", { + threadId: child, turnId: "child-first", serverName: "auth", mode: "url", _meta: null, + message: "Authorize", url: "https://example.com/authorize", elicitationId, + })).toMatchObject({action: "accept"}); + if (transition === "replay") { + await h.request(version === 1 ? "session/load" : "session/resume", { + sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + } else { + const prompt = h.sendPrompt([{type: "text", text: "Continue"}]); + await vi.waitFor(() => expect(h.turnStartParams).toHaveLength(2)); + h.emit(turnStarted("turn-2")); + const id = h.turnStartParams[1]!["clientUserMessageId"]; + h.emit(itemStarted(userMessageItem(typeof id === "string" ? id : null, "Continue"), "turn-2")); + h.emit(turnCompleted("turn-2")); + await prompt; + await h.promptRunFinished(1); + } + h.emit({method: "serverRequest/resolved", params: {threadId: child, requestId: `request-${transition}`}}); + await flush(h); + expect(h.transcript.filter(entry => "elicitationComplete" in entry)).toContainEqual({ + elicitationComplete: {elicitationId}, + }); + } + }); + + it("reannounces ancestors before an existing nested child's metadata during replay", async () => { + const h = await connect(); + h.emit(activity("coordinator", "/root/coordinator")); + h.emit(activity(child, "/root/coordinator/worker")); + await flush(h); + const start = h.scopedUpdates.length; + history(h, [], [[]]); + h.setCodexResponse("thread/read", async params => { + const {threadId, includeTurns} = params as {threadId: string; includeTurns?: boolean}; + if (threadId === sessionId && includeTurns) { + h.emit(activity(child, "/root/coordinator/renamed")); + } + return {thread: {...createThread(), id: threadId, sessionId: threadId, turns: []}}; + }); + await h.request(version === 1 ? "session/load" : "session/resume", { + sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + await flush(h); + const known = new Set([sessionId]); + const updates = h.scopedUpdates.slice(start); + for (const entry of updates) { + expect(known.has(entry.sessionId), `unknown scope ${entry.sessionId}`).toBe(true); + if (entry.update.sessionUpdate === "subagent_update" && typeof entry.update.sessionId === "string") { + known.add(entry.update.sessionId); + } + } + expect(updates.some(({update}) => update.sessionUpdate === "subagent_update" && update.title === "Renamed")).toBe(true); + }); + it("announces once and keeps the same identity across idle and later work", async () => { const h = await connect(); h.emit(activity()); @@ -393,20 +502,86 @@ describe.each([1, 2] as const)("ACP v%s standard child sessions", version => { expect(roster.map(({sessionId}) => sessionId)).toEqual([sessionId, "fork"]); }); - it("serializes same-parent replay through the preceding response and live-traffic boundary", async () => { + it.skipIf(version !== 2).each([false, true])( + "holds fresh child snapshots until a delayed response batch is written (list fails: %s)", async fails => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + 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; + if (fails) throw new Error("Delayed list failure"); + return {sessions: []}; + }); + const resume = vi.spyOn(h.agent, "resumeSessionV2"); + const start = h.wireMessages.length; + const batch = h.requestBatch([ + {method: "session/resume", params: {sessionId, cwd, replayFrom: {type: "start"}}}, + {method: "session/list", params: {}}, + ]); + batch.catch(() => {}); + try { + await vi.waitFor(() => expect(resume.mock.results).toHaveLength(1)); + await resume.mock.results[0]!.value; + expect(list).toHaveBeenCalledTimes(1); + // Exercise the old next-turn release while the SDK still withholds the batch. + await new Promise(resolve => setImmediate(resolve)); + await flush(h); + expect(h.wireMessages.slice(start).some(Array.isArray)).toBe(false); + expect(h.wireMessages.slice(start).some(childStateFrame)).toBe(false); + } finally { + release(); + } + if (fails) await expect(batch).rejects.toMatchObject({code: -32603}); + else await batch; + await vi.waitFor(() => expect(h.wireMessages.slice(start).some(childStateFrame)).toBe(true)); + const frames = h.wireMessages.slice(start); + const boundary = frames.findIndex(Array.isArray); + expect(boundary).toBeGreaterThanOrEqual(0); + expect(frames.findIndex(childStateFrame)).toBeGreaterThan(boundary); + }, + ); + + it.skipIf(version !== 2)("completes two same-parent replays in one batch without losing live traffic", async () => { + const h = await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-first"), child)); + await flush(h); + let reads = 0; + history(h, [{type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}], + [[]], () => { + if (++reads === 1) { + h.emit(onThread(agentMessageDelta("Live across batch", "child-first", "batch-message"), child)); + } + }); + const start = h.wireMessages.length; + await h.requestBatch([ + {method: "session/resume", params: {sessionId, cwd, replayFrom: {type: "start"}}}, + {method: "session/resume", params: {sessionId, cwd, replayFrom: {type: "start"}}}, + ]); + expect(reads).toBe(2); + await vi.waitFor(() => expect(h.wireMessages.slice(start).some(childStateFrame)).toBe(true)); + await flush(h); + const frames = h.wireMessages.slice(start); + expect(frames.findIndex(childStateFrame)).toBeGreaterThan(frames.findIndex(Array.isArray)); + expect(childUpdates(h).filter(({update}) => hasText(update, "Live across batch"))).toHaveLength(1); + }); + + it("serializes same-parent attachments without losing deferred live traffic", async () => { const h = await connect(); let release!: () => void; const held = new Promise(resolve => { release = resolve; }); let reads = 0; - let firstResponded = false; history(h, [{type: "subAgentActivity", id: "started", kind: "started", agentThreadId: child, agentPath: "/root/worker"}], [[]], async () => { reads++; if (reads === 1) await held; - else expect(firstResponded).toBe(true); }); const params = {sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {})}; const method = version === 1 ? "session/load" : "session/resume"; - const first = h.request(method, params).then(() => { firstResponded = true; }); + const first = h.request(method, params); await vi.waitFor(() => expect(reads).toBe(1)); const second = h.request(method, params); await h.request("session/list", {}); @@ -418,6 +593,7 @@ describe.each([1, 2] as const)("ACP v%s standard child sessions", version => { release(); await first; await second; + await vi.waitFor(() => expect(childUpdates(h).some(({update}) => hasText(update, "Live result"))).toBe(true)); await flush(h); expect(reads).toBe(2); expect(childUpdates(h).filter(({update}) => hasText(update, "Live result"))).toHaveLength(1); diff --git a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts index c3b90cda5..b18881fa3 100644 --- a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts +++ b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts @@ -104,6 +104,8 @@ export type TranscriptEntry = | {elicitationComplete: acpV2.CompleteElicitationNotification}; export type PromptSession = { + wireMessages: acpV2.AnyWireMessage[]; + requestBatch(requests: Array<{method: string; params: unknown}>): Promise; connection: {close(): void}; transcript: TranscriptEntry[]; scopedUpdates: Array<{sessionId: string; update: acpV2.SessionUpdate | acp.SessionUpdate}>; @@ -158,6 +160,7 @@ 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>( @@ -212,10 +215,20 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { 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, ctx.params.sessionId)) @@ -244,6 +257,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, @@ -265,7 +285,10 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { if (!options.onElicitation) throw new Error("No elicitation handler configured"); return await options.onElicitation(request, ctx.signal) as acp.CreateElicitationResponse; }) - .connect(clientStream); + .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} : {}), @@ -307,6 +330,8 @@ export async function connectSession(protocolVersion: 1 | 2 = 2, options: { }; return { + wireMessages, + requestBatch, connection, transcript, scopedUpdates, diff --git a/src/__tests__/integration/mock-responses-server.ts b/src/__tests__/integration/mock-responses-server.ts index 7f48b73ae..18c1288c5 100644 --- a/src/__tests__/integration/mock-responses-server.ts +++ b/src/__tests__/integration/mock-responses-server.ts @@ -2,6 +2,7 @@ import http from "node:http"; import type {AddressInfo} from "node:net"; type MockErrorResponse = {status: number; body: unknown}; +type ResponseHandler = (request: unknown) => string | undefined | Promise; /** * Local stand-in for the OpenAI Responses API. Each `POST /v1/responses` from Codex gets the next queued reply; @@ -19,7 +20,7 @@ export interface MockResponsesServer { /** Keeps returning the HTTP error across runtime retries, until cleared with null. */ setErrorResponse(response: MockErrorResponse | null): void; /** Selects replies by request instead of arrival order, for concurrent root/child/title calls. */ - setResponseHandler(handler: (request: unknown) => string | undefined): void; + setResponseHandler(handler: ResponseHandler): void; close(): Promise; } @@ -35,7 +36,7 @@ function parseBody(body: string): unknown { export async function startMockResponsesServer(): Promise { const queue: string[] = []; let errorResponse: MockErrorResponse | null = null; - let responseHandler: ((request: unknown) => string | undefined) | undefined; + let responseHandler: ResponseHandler | undefined; const requests: unknown[] = []; let unexpectedRequests = 0; let requestCount = 0; @@ -43,12 +44,20 @@ export async function startMockResponsesServer(): Promise { const server = http.createServer((req, res) => { let body = ""; req.on("data", (chunk) => body += chunk); - req.on("end", () => { + req.on("end", async () => { requestCount++; const parsed = parseBody(body); requests.push(parsed); const isResponsesCall = req.method === "POST" && (req.url ?? "").split("?")[0]!.endsWith("/responses"); - const next = isResponsesCall ? errorResponse ?? (responseHandler ? responseHandler(parsed) : queue.shift()) : undefined; + let next: string | MockErrorResponse | undefined; + try { + next = isResponsesCall ? errorResponse ?? (responseHandler ? await responseHandler(parsed) : queue.shift()) : undefined; + } catch (error) { + res.writeHead(500); + res.end(String(error)); + return; + } + if (res.destroyed) return; if (next === undefined) { unexpectedRequests++; res.writeHead(isResponsesCall ? 400 : 404); diff --git a/src/__tests__/integration/standard-subagents.test.ts b/src/__tests__/integration/standard-subagents.test.ts index c478b6f87..10161d226 100644 --- a/src/__tests__/integration/standard-subagents.test.ts +++ b/src/__tests__/integration/standard-subagents.test.ts @@ -11,10 +11,22 @@ import {describeIntegration, INTEGRATION_MODEL_ID} from "./integration-fixture"; import {functionCallResponse, startMockResponsesServer, textResponse} from "./mock-responses-server"; describeIntegration("integration: standard subagent sessions", () => { - it.each([1, 2] as const)("announces and routes real Codex children through ACP v%s", async version => { + it.each(([1, 2] as const).flatMap(version => + (["complete", "cancel", "close"] as const).map(ending => ({version, ending})), + ))("replays active Codex children through ACP v$version ($ending)", async ({version, ending}) => { const mock = await startMockResponsesServer(); let delegated = false; - mock.setResponseHandler(request => { + let childRequested = false; + let releaseChild!: () => void; + const childResponse = new Promise(resolve => { releaseChild = resolve; }); + mock.setResponseHandler(async request => { + const metadata = (request as {client_metadata?: Record}).client_metadata?.["x-codex-turn-metadata"]; + const agentName = metadata ? (JSON.parse(metadata) as {agent_name?: string}).agent_name : undefined; + if (!childRequested && agentName === "/root/worker") { + childRequested = true; + await childResponse; + return textResponse("CHILD_RESULT"); + } const model = (request as {model?: string}).model; if (model === INTEGRATION_MODEL_ID.model && !delegated) { delegated = true; @@ -53,6 +65,7 @@ describeIntegration("integration: standard subagent sessions", () => { : 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 { @@ -76,6 +89,44 @@ describeIntegration("integration: standard subagent sessions", () => { const prompt = request("session/prompt", {sessionId, prompt: [{type: "text", text: "Delegate once"}]}); 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; + await vi.waitFor(() => expect(updates.some(entry => state(entry)?.state === "running")).toBe(true)); + await prompt; + if (version === 2) { + await vi.waitFor(() => expect(updates.filter(entry => entry.sessionId === sessionId + && entry.update.sessionUpdate === "state_update").at(-1)?.update.state).toBe("idle")); + } + const load = (id: string) => request(version === 1 ? "session/load" : "session/resume", { + sessionId: id, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + const replayStart = updates.length; + await load(sessionId); + await vi.waitFor(() => expect(updates.slice(replayStart).some(entry => state(entry)?.state === "running")).toBe(true)); + const boundary = updates.length; + if (ending === "complete") { + const fork = await request("session/fork", {sessionId, cwd, mcpServers: []}); + await load(fork.sessionId); + const forkChildren = updates.filter(entry => entry.sessionId === fork.sessionId && entry.update.sessionUpdate === "subagent_update"); + expect(forkChildren.length, JSON.stringify(updates)).toBeGreaterThan(0); + expect(forkChildren.every(entry => entry.update.sessionId !== childId)).toBe(true); + await request("session/close", {sessionId: fork.sessionId}); + } else { + 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}\n${JSON.stringify(updates)}`).toBe(true), {timeout: 15_000}); + } + releaseChild(); + if (ending === "complete") await vi.waitFor(() => { const child = updates.find(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "subagent_update"); const requests = mock.requests.map(request => { const body = request as {model?: string; tools?: Array<{name?: string; type?: string}>; input?: Array<{type?: string}>}; @@ -95,6 +146,7 @@ describeIntegration("integration: standard subagent sessions", () => { expect(updates.some(entry => ["subagent_spawned", "subagent_state_update", "_subagent_update"].includes(entry.update.sessionUpdate))).toBe(false); expect(mock.unexpectedRequests).toBe(0); } finally { + releaseChild(); connection.close(); child.kill("SIGTERM"); if (child.exitCode === null) await once(child, "exit"); diff --git a/src/subagents/StandardSubagentEventRouter.ts b/src/subagents/StandardSubagentEventRouter.ts index 3d45e18e9..305fcb781 100644 --- a/src/subagents/StandardSubagentEventRouter.ts +++ b/src/subagents/StandardSubagentEventRouter.ts @@ -52,6 +52,7 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { private retired = false; private snapshotting = false; private readonly snapshots = new Map(); + private interactionObserver?: (event: ServerNotification, sessionId: string) => Promise; constructor( private readonly rootSessionId: string, @@ -109,6 +110,7 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { 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) { @@ -126,6 +128,7 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { 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 modelProgress = notification.method === "item/agentMessage/delta" @@ -446,6 +449,10 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { .map(child => ({threadId: child.threadId, turnId: child.turnId})); } + setInteractionObserver(observer: (event: ServerNotification, sessionId: string) => Promise): void { + this.interactionObserver = observer; + } + isRetired(): boolean { return this.retired; } diff --git a/src/subagents/SubagentInteractionHandlers.ts b/src/subagents/SubagentInteractionHandlers.ts new file mode 100644 index 000000000..57c456aaf --- /dev/null +++ b/src/subagents/SubagentInteractionHandlers.ts @@ -0,0 +1,32 @@ +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 interaction state survives parent prompt and attachment changes, like the child conversation. */ +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(); + // StandardSubagentEventRouter supplies each request's cancellation scope through SubagentInteraction. + 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); + } +} diff --git a/vitest.config.ts b/vitest.config.mjs similarity index 100% rename from vitest.config.ts rename to vitest.config.mjs From 5030668054785c2bbe31706ad1cbc828496dc01d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 21 Oct 2026 12:23:45 +0200 Subject: [PATCH 3/5] fix: retain child state across restoration and parent turns --- readme-dev.md | 7 + src/AcpV2ForegroundState.ts | 7 +- src/CodexAcpClient.ts | 45 ++- src/CodexAcpServer.ts | 82 +++-- src/CodexEventHandler.ts | 90 ++--- src/CodexItemStreamState.ts | 14 + src/CodexSessionToolCalls.ts | 25 +- ...andard-lifecycle-v1-blocked-next-turn.json | 47 +++ .../data/standard-lifecycle-v1-blocked.json | 21 ++ .../data/standard-lifecycle-v1-cleanup.json | 37 ++ ...tandard-lifecycle-v1-completed-replay.json | 62 ++++ ...standard-lifecycle-v1-historical-only.json | 22 ++ ...andard-lifecycle-v1-nested-historical.json | 31 ++ ...andard-lifecycle-v1-restored-activity.json | 74 ++++ .../data/standard-lifecycle-v1-restored.json | 74 ++++ .../data/standard-lifecycle-v1-terminal.json | 41 +++ ...andard-lifecycle-v2-blocked-next-turn.json | 57 +++ .../data/standard-lifecycle-v2-blocked.json | 28 ++ .../data/standard-lifecycle-v2-cleanup.json | 49 +++ ...tandard-lifecycle-v2-completed-replay.json | 70 ++++ ...standard-lifecycle-v2-historical-only.json | 30 ++ ...andard-lifecycle-v2-nested-historical.json | 39 ++ ...andard-lifecycle-v2-restored-activity.json | 111 ++++++ .../data/standard-lifecycle-v2-restored.json | 111 ++++++ .../data/standard-lifecycle-v2-terminal.json | 48 +++ .../standard-subagent-lifecycle.test.ts | 348 ++++++++++++++++++ .../CodexACPAgent/v2-prompt-harness.ts | 2 +- .../integration/standard-subagents.test.ts | 46 ++- src/subagents/CodexSubagentSubscriptions.ts | 56 ++- src/subagents/StandardSubagentEventRouter.ts | 147 ++++++-- src/tool-calls/reporters/CommandReporter.ts | 12 + 31 files changed, 1691 insertions(+), 142 deletions(-) create mode 100644 src/CodexItemStreamState.ts create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-blocked-next-turn.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-blocked.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-cleanup.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-completed-replay.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-historical-only.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-nested-historical.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-restored-activity.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-restored.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-terminal.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-blocked-next-turn.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-blocked.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-cleanup.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-completed-replay.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-historical-only.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-nested-historical.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-restored-activity.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-restored.json create mode 100644 src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-terminal.json create mode 100644 src/__tests__/CodexACPAgent/standard-subagent-lifecycle.test.ts diff --git a/readme-dev.md b/readme-dev.md index 75609513b..c8e402f6e 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -199,6 +199,8 @@ Work state is independent of conversation lifetime. An observed work ending repo its stop reason, not a permanently completed child. Lost observability reports `unknown`, not a fabricated failure. On v2, the same state also appears on the child's own `state_update` stream. Child work, plans, permissions, and elicitation can outlive the parent's foreground prompt. +Child item-rendering state is conversation-owned too, so a new parent prompt does not reset +an active child's terminal or streamed item tracking. Child Markdown plans use completed snapshots rather than interim draft deltas. Parent cancellation interrupts observed descendant work and withdraws child interactions; late answers cannot restart finished work. Closing or replacing the runtime invalidates those scopes. @@ -209,6 +211,11 @@ Responding to child-scoped Agent-to-Client requests is still allowed. Replayed a transcripts do not grant controls or confirm current activity; overlapping completed items are deduplicated and fresh work snapshots follow the replay response. A copied history alone never authorizes interrupting the original child runtime. +Restored child routes require provider-confirmed parentage through the verified ancestor chain. +Only already-loaded runtimes are rejoined; inactive owned children are not loaded just for replay, +but their routes can receive later live work. Foreign or unverified fork descendants stay history-only. +Child history uses the same traversal for both Codex spawn formats and reconciles completed +items with live tool tracking before suppressing overlapping notifications. Load/resume attachment operations for the same standard parent are serialized. Fresh child traffic follows the actual response write, including when the response is part of a batch; overlapping attachments publish after the latest setup's response rather than interleaving diff --git a/src/AcpV2ForegroundState.ts b/src/AcpV2ForegroundState.ts index 79360fc55..a373aa5e5 100644 --- a/src/AcpV2ForegroundState.ts +++ b/src/AcpV2ForegroundState.ts @@ -105,7 +105,12 @@ export class AcpV2ForegroundState { } private publish(state: acpV2.StateUpdate): Promise { - const sent = this.sending.then(() => this.notify(state)); + return this.serializePublication(() => this.notify(state)); + } + + /** Replay flushes share the same ordering as live state publication. */ + serializePublication(publish: () => Promise): Promise { + const sent = this.sending.then(publish); this.sending = sent.catch(() => {}); return sent; } diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index e2e5a1fec..7f21012a6 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -84,6 +84,7 @@ import { } from "./GatewayModelDiscovery"; import {findSupportedEffort} from "./ModelConfigOption"; export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; +export type SubagentHistoryItem = {item: ThreadItem; completed: boolean; turnId?: string}; /** * The slice of `thread/resume` the session layer consumes, plus whether Codex @@ -827,13 +828,20 @@ export class CodexAcpClient { } /** A standard child is one conversation, not one synthetic session per delegation. */ - async readSessionItems(sessionId: string): Promise> { + async readSessionItems(sessionId: string): Promise<{ + thread: Thread; + items: AsyncIterable; + }> { const metadata = await this.codexClient.threadRead({threadId: sessionId}); if (metadata.thread.historyMode === "legacy") { - const legacy = await this.codexClient.threadRead({threadId: sessionId, includeTurns: true}); - return oneItemPage(legacy.thread.turns.flatMap(turn => turn.items)); + return {thread: metadata.thread, items: legacyHistoryItems(this.codexClient, sessionId)}; } - return this.codexClient.threadItemPages(sessionId); + const entries = this.codexClient.threadItemEntryPages(sessionId); + return {thread: metadata.thread, items: completedHistoryItems(entries)}; + } + + restoreSubagentSubscription(rootSessionId: string, parentThreadId: string, thread: Thread): Promise { + return this.subagents.restoreChild(rootSessionId, parentThreadId, thread); } async newSession(request: WithAcpMcpServers): Promise { @@ -1285,6 +1293,20 @@ export class CodexAcpClient { } } + /** Serializes a setup transition with the notifications it changes routing for. */ + enqueueSessionTransition(sessionId: string, operation: () => void): Promise { + return new Promise((resolve, reject) => { + this.enqueueSessionNotification(sessionId, () => { + try { + operation(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + } + private enqueueSessionNotification(sessionId: string, operation: () => void | Promise): void { const run = async () => { try { @@ -1789,3 +1811,18 @@ async function* noItems(): AsyncGenerator {} async function* oneItemPage(items: T[]): AsyncGenerator { if (items.length > 0) yield items; } + +async function* completedHistoryItems(pages: AsyncIterable): AsyncGenerator { + for await (const page of pages) { + yield page.map(({item, turnId, completedAtMs}) => ({ + item, turnId, completed: "status" in item ? item.status !== "inProgress" : completedAtMs !== null, + })); + } +} + +async function* legacyHistoryItems(client: CodexAppServerClient, threadId: string): AsyncGenerator { + const {thread} = await client.threadRead({threadId, includeTurns: true}); + yield thread.turns.flatMap(turn => turn.items.map(item => ({ + item, turnId: turn.id, completed: "status" in item ? item.status !== "inProgress" : turn.status !== "inProgress", + }))); +} diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index bbe4a1dd9..002503aba 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -15,6 +15,7 @@ import { OPENAI_PROVIDER_ID, type SessionMetadata, type SessionMetadataWithThread, + type SubagentHistoryItem, type UrlElicitationRequester } from "./CodexAcpClient"; import { @@ -650,7 +651,8 @@ export class CodexAcpServer { const release = (childId: string) => this.reportingConnection.reports.releaseOpen(childId); const router = this.usesStandardSubagents() ? new StandardSubagentEventRouter(sessionId, session, this.subagentAssociations, release, this.capabilities, replay, - this.sessions.get(sessionId)?.subagentTransition) + this.sessions.get(sessionId)?.subagentTransition, + threadId => this.codexAcpClient.appServerClient.runningTurnId(threadId)) : new CodexSubagentEventRouter(sessionId, this.supportsNativeSubagents(), session, release); if (router instanceof StandardSubagentEventRouter) this.getSubagentInteractionHandlers(router, sessionId); return router; @@ -1101,14 +1103,20 @@ export class CodexAcpServer { } } - private beginSessionOpen(sessionId: string): number { - const subagents = this.sessions.get(sessionId)?.subagents; - if (subagents instanceof StandardSubagentEventRouter) subagents.pauseForSetup(); + 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 ${sessionId} closed while setup was queued`); + } + const subagents = this.sessions.get(sessionId)?.subagents; + if (subagents instanceof StandardSubagentEventRouter) subagents.pauseForSetup(); + this.sessionOpenGenerations.set(sessionId, generation); + }; + // An admitted child event finishes before replay changes its publication boundary. + if (this.usesStandardSubagents()) await this.codexAcpClient.enqueueSessionTransition(sessionId, begin); + else begin(); return generation; } @@ -1207,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 ?? []; @@ -1288,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; @@ -3296,7 +3304,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; @@ -3480,7 +3488,7 @@ export class CodexAcpServer { isOpen, ); if (this.usesStandardSubagents()) { - await this.streamStandardThreadHistory(sessionId, itemPages, sessionState, new Set([sessionId]), isOpen); + await this.streamStandardThreadHistory(sessionId, standardHistoryItems(itemPages), sessionState, new Set([sessionId]), isOpen); return; } if (this.supportsNativeSubagents()) { @@ -3661,7 +3669,7 @@ export class CodexAcpServer { private async streamStandardThreadHistory( sessionId: string, - itemPages: AsyncIterable, + itemPages: AsyncIterable, sessionState: SessionState, ancestry: Set, isOpen: () => boolean, @@ -3672,25 +3680,33 @@ export class CodexAcpServer { if (!(router instanceof StandardSubagentEventRouter)) return; const session = new ACPSessionConnection(this.connection, sessionId); const started = new Set(); - const replayChild = async (item: ThreadItem & {type: "subAgentActivity"}) => { - if (isRootAgentPath(item.agentPath) || ancestry.has(item.agentThreadId)) return; - const childId = await router.announceHistory(item.agentThreadId, item.agentPath); - if (childId === null || replayedChildren.has(item.agentThreadId)) return; - replayedChildren.add(item.agentThreadId); + const replayChild = async (childThreadId: string, childId: string | null) => { + if (childId === null || ancestry.has(childThreadId) || replayedChildren.has(childThreadId)) return; + replayedChildren.add(childThreadId); try { - const history = await this.codexAcpClient.readSessionItems(item.agentThreadId); + const history = await this.codexAcpClient.readSessionItems(childThreadId); + if (!isOpen()) throw new SessionClosedDuringLoadError(); + const parentThreadId = router.parentThreadId(childThreadId); + if (parentThreadId !== undefined) { + try { + const runtime = await this.codexAcpClient.restoreSubagentSubscription(sessionState.sessionId, parentThreadId, history.thread); + if (runtime) await router.restoreRuntime(runtime, this.codexAcpClient.appServerClient.runningTurnId(childThreadId)); + } catch (error) { + logger.error(`Failed to restore subagent subscription ${childThreadId}`, error); + } + } await this.streamStandardThreadHistory( - childId, history, sessionState, new Set([...ancestry, item.agentThreadId]), - isOpen, item.agentThreadId, replayedChildren, + childId, history.items, sessionState, new Set([...ancestry, childThreadId]), + isOpen, childThreadId, replayedChildren, ); } catch (error) { if (error instanceof SessionClosedDuringLoadError) throw error; - logger.error(`Failed to replay subagent ${item.agentThreadId}`, error); + logger.error(`Failed to replay subagent ${childThreadId}`, error); } }; const unresolved: Array = []; for await (const items of itemPages) { - for (const item of items) { + for (const {item, completed, turnId} of items) { if (!isOpen()) throw new SessionClosedDuringLoadError(); // Native child input comes from agents, not ordinary client user messages. if (threadId !== sessionState.sessionId && item.type === "userMessage") continue; @@ -3698,28 +3714,32 @@ export class CodexAcpServer { if (item.senderThreadId === threadId) { for (const child of item.receiverThreadIds) { if (child.trim() && !ancestry.has(child)) { - await router.announce(child, sessionId, undefined, item.prompt ?? undefined); + const childId = await router.announce(child, sessionId, undefined, item.prompt ?? undefined); + await replayChild(child, childId); } } } continue; } if (item.type === "subAgentActivity") { - await replayChild(item); + if (isRootAgentPath(item.agentPath) || ancestry.has(item.agentThreadId)) continue; + await replayChild(item.agentThreadId, await router.announceHistory(item.agentThreadId, item.agentPath)); if (!replayedChildren.has(item.agentThreadId)) unresolved.push(item); continue; } - const partial = "status" in item && item.status === "inProgress"; + const partial = !completed; // Running commands may overlap buffered live output; replay their metadata, not bytes twice. const replayItem = item.type === "commandExecution" && partial ? {...item, aggregatedOutput: null} : item; for (const update of await this.createHistoryUpdates(replayItem, sessionState)) { if (this.protocolVersion === 2) await this.sendReplayMessageStart(session, update, started); await session.update(update); } - if (threadId !== sessionState.sessionId && !partial) router.rememberReplayedItem(threadId, item.id); + if (threadId !== sessionState.sessionId) router.reconcileHistoryItem(threadId, item, completed, turnId); } } - for (const item of unresolved) await replayChild(item); + for (const item of unresolved) { + await replayChild(item.agentThreadId, await router.announceHistory(item.agentThreadId, item.agentPath)); + } if (!isOpen()) throw new SessionClosedDuringLoadError(); // History-only children have no confirmed current state or enabled mutations. } @@ -5520,6 +5540,12 @@ async function* itemsOfEntries(pages: AsyncIterable): AsyncGe } } +async function* standardHistoryItems(pages: AsyncIterable): AsyncGenerator { + for await (const page of pages) { + yield page.map(item => ({item, completed: !("status" in item) || item.status !== "inProgress"})); + } +} + function isUuidV7(id: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id); } diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index ac62a1101..1cb5a3984 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -36,12 +36,12 @@ import type { import {toTokenCount} from "./TokenCount"; import { stripShellPrefix } from "./CommandUtils"; import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer"; -import {CommandReporter, commandUsesTerminal} from "./tool-calls/reporters/CommandReporter"; +import {commandUsesTerminal} from "./tool-calls/reporters/CommandReporter"; import {CompactionReporter} from "./tool-calls/reporters/CompactionReporter"; import {DynamicToolReporter} from "./tool-calls/reporters/DynamicToolReporter"; import {FileChangeReporter} from "./tool-calls/reporters/FileChangeReporter"; -import {FuzzySearchReporter} from "./tool-calls/reporters/FuzzySearchReporter"; -import {GuardianReporter} from "./tool-calls/reporters/GuardianReporter"; +import {CodexItemStreamState} from "./CodexItemStreamState"; +import {PLAIN_TOOL_CALL_ITEM_TYPES} from "./CodexSessionToolCalls"; import {ImageGenerationReporter} from "./tool-calls/reporters/ImageGenerationReporter"; import {ImageViewReporter} from "./tool-calls/reporters/ImageViewReporter"; import {McpToolReporter} from "./tool-calls/reporters/McpToolReporter"; @@ -183,22 +183,6 @@ const STRUCTURED_CODEX_ERROR_CATEGORIES = { activeTurnNotSteerable: "provider_error", } satisfies Record; -/** - * Item types that render as a plain `tool_call`/`tool_call_update` and have no outstanding-item - * tracker of their own (`contextCompaction` has `CodexSessionCompactions`; `collabAgentToolCall`/ - * `subAgentActivity` have `CodexSubagentEventRouter`). Used to track which open items a provider - * restart's close-out needs to fail. - */ -const PLAIN_TOOL_CALL_ITEM_TYPES = new Set([ - "fileChange", - "commandExecution", - "mcpToolCall", - "dynamicToolCall", - "webSearch", - "imageView", - "imageGeneration", -]); - /** Prompt failures whose text the client has already received as an agent message. */ const failuresShownAsMessages = new WeakSet(); @@ -222,17 +206,11 @@ export class CodexEventHandler { private failure: RequestError | null = null; private terminalTurnError: {turnId: string; error: ErrorNotification["error"]} | null = null; private completedPlan: CompletedPlan | null = null; - private readonly activeImageGenerationItems = new Set(); - private readonly emittedImageViewItems = new Set(); private readonly session: ACPSessionConnection; private readonly renderer: AcpToolCallRenderer; - private readonly commands = new CommandReporter(); - private readonly fuzzySearches = new FuzzySearchReporter(); - private readonly guardianReviews = new GuardianReporter(); + private readonly rootStreams = new CodexItemStreamState(); private readonly plans: CodexPlanStream; private disposed = false; - private readonly seenReasoningDeltaItemIds = new Set(); - private readonly agentMessagePhases = new Map(); private readonly turnDiffs = new Map(); private readonly oversizedTurnDiffs = new Set(); /** Latest diff of each subagent turn, keyed by child thread and turn; `null` marks an oversized diff. */ @@ -440,19 +418,18 @@ export class CodexEventHandler { if (notification.method === "turn/diff/updated" && notification.params.threadId !== this.sessionState.sessionId) { this.recordChildTurnDiff(notification.params); } - await this.flushPendingErrors(); if (this.subagents instanceof StandardSubagentEventRouter - && typeof threadId === "string" && threadId !== this.sessionState.sessionId - && !this.subagents.isNativeSubagentThread(threadId)) { - await this.subagents.handle(notification); + && !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) { - for (const update of this.subagents.openToolCalls(child.threadId)?.finishOutstanding() ?? []) { + 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); } } @@ -589,7 +566,7 @@ export class CodexEventHandler { case "item/started": { this.completeRetryIncidentOnTurnProgress(notification.params.threadId); const update = await this.createItemEvent(notification.params); - this.trackOpenToolCall(notification.params.item, notification.params.threadId); + this.trackOpenToolCall(notification.params.item, notification.params.threadId, notification.params.turnId); return update; } case "item/completed": @@ -675,7 +652,7 @@ export class CodexEventHandler { }); case "item/commandExecution/outputDelta": this.completeRetryIncidentOnTurnProgress(notification.params.threadId); - return this.renderer.render(this.commands.outputDelta(notification.params.itemId, notification.params.delta)); + return this.renderer.render(this.streamsFor(notification.params.threadId).commands.outputDelta(notification.params.itemId, notification.params.delta)); case "item/mcpToolCall/progress": this.completeRetryIncidentOnTurnProgress(notification.params.threadId); // AIR does not show MCP progress. @@ -699,9 +676,9 @@ export class CodexEventHandler { case "deprecationNotice": return this.createDeprecationNoticeEvent(notification.params); case "item/autoApprovalReview/started": - return this.renderer.render(this.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.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( @@ -720,15 +697,15 @@ export class CodexEventHandler { case "model/rerouted": return this.createModelReroutedEvent(notification.params); case "fuzzyFileSearch/sessionUpdated": - return this.renderer.render(this.fuzzySearches.updated(notification.params)); + return this.renderer.render(this.rootStreams.fuzzySearches.updated(notification.params)); case "fuzzyFileSearch/sessionCompleted": - return this.renderer.render(this.fuzzySearches.completed(notification.params)); + return this.renderer.render(this.rootStreams.fuzzySearches.completed(notification.params)); case "thread/goal/updated": return this.createThreadGoalUpdatedEvent(notification.params); case "thread/goal/cleared": return this.createThreadGoalClearedEvent(notification.params); case "item/commandExecution/terminalInteraction": - return this.renderer.render(this.commands.terminalInput( + return this.renderer.render(this.streamsFor(notification.params.threadId).commands.terminalInput( notification.params.itemId, notification.params.stdin, )); @@ -803,7 +780,7 @@ export class CodexEventHandler { } private async createTextEvent(event: AgentMessageDeltaNotification): Promise { - const phase = this.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); } @@ -875,12 +852,12 @@ export class CodexEventHandler { private createReasoningDeltaEvent( event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification ): UpdateSessionEvent { - this.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.seenReasoningDeltaItemIds.add(event.itemId); + this.streamsFor(event.threadId).seenReasoningDeltaItemIds.add(event.itemId); return this.createAgentThoughtEvent("\n\n", event.itemId); } @@ -896,7 +873,7 @@ export class CodexEventHandler { this.sessionState.clientCapabilities.diffPatchFormat, )); case "commandExecution": - return this.renderer.render(this.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": @@ -904,15 +881,15 @@ export class CodexEventHandler { case "webSearch": return this.renderer.render(WebSearchReporter.started(event.item)); case "imageView": - this.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.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 @@ -944,18 +921,18 @@ export class CodexEventHandler { case "mcpToolCall": return this.renderer.render(McpToolReporter.completed(event.item)); case "commandExecution": - return this.renderer.render(this.commands.completed(event.item, true)); + return this.renderer.render(this.streamsFor(event.threadId).commands.completed(event.item, true)); case "imageView": - if (this.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.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.seenReasoningDeltaItemIds.delete(event.item.id)) { + if (this.streamsFor(event.threadId).seenReasoningDeltaItemIds.delete(event.item.id)) { return null; } return this.createCompletedReasoningEvent(event.item); @@ -964,7 +941,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); @@ -989,8 +966,13 @@ export class CodexEventHandler { } } - private rememberAgentMessagePhase(item: ThreadItem & { type: "agentMessage" }): void { - this.agentMessagePhases.set(item.id, item.phase); + private streamsFor(threadId: string): CodexItemStreamState { + return this.subagents instanceof StandardSubagentEventRouter + ? this.subagents.itemStreams(threadId) ?? this.rootStreams : this.rootStreams; + } + + 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 { @@ -1034,10 +1016,10 @@ export class CodexEventHandler { : this.sessionState.openToolCalls; } - private trackOpenToolCall(item: ThreadItem, threadId: string): void { + 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.openToolCalls(threadId).start(item.id, hasTerminal); + this.openToolCalls(threadId).start(item.id, hasTerminal, turnId); } private async updatePlan(event: TurnPlanUpdatedNotification): Promise { diff --git a/src/CodexItemStreamState.ts b/src/CodexItemStreamState.ts new file mode 100644 index 000000000..45e135edc --- /dev/null +++ b/src/CodexItemStreamState.ts @@ -0,0 +1,14 @@ +import {CommandReporter} from "./tool-calls/reporters/CommandReporter"; +import {FuzzySearchReporter} from "./tool-calls/reporters/FuzzySearchReporter"; +import {GuardianReporter} from "./tool-calls/reporters/GuardianReporter"; + +/** Item presentation state that standard children retain across parent handler changes. */ +export class CodexItemStreamState { + readonly commands = new CommandReporter(); + readonly fuzzySearches = new FuzzySearchReporter(); + readonly guardianReviews = new GuardianReporter(); + readonly activeImageGenerationItems = new Set(); + readonly emittedImageViewItems = new Set(); + readonly seenReasoningDeltaItemIds = new Set(); + readonly agentMessagePhases = new Map(); +} diff --git a/src/CodexSessionToolCalls.ts b/src/CodexSessionToolCalls.ts index 5b5be5d69..0ef460dd0 100644 --- a/src/CodexSessionToolCalls.ts +++ b/src/CodexSessionToolCalls.ts @@ -1,4 +1,10 @@ import type {UpdateSessionEvent} from "./ACPSessionConnection"; +import type {ThreadItem} from "./app-server/v2"; +import {commandUsesTerminal} from "./tool-calls/reporters/CommandReporter"; + +export const PLAIN_TOOL_CALL_ITEM_TYPES = new Set([ + "fileChange", "commandExecution", "mcpToolCall", "dynamicToolCall", "webSearch", "imageView", "imageGeneration", +]); /** * Tracks tool-call items that reported `item/started` but not yet `item/completed`, for the item @@ -9,25 +15,32 @@ 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 { this.open.delete(itemId); } + restore(item: ThreadItem, completed: boolean, turnId?: string): void { + if (!PLAIN_TOOL_CALL_ITEM_TYPES.has(item.type)) return; + if (completed) this.complete(item.id); + else if (!this.open.has(item.id)) this.start(item.id, item.type === "commandExecution" && commandUsesTerminal(item), turnId); + } + /** Drops all tracked entries without emitting anything, e.g. when their turn ends. */ clear(): void { this.open.clear(); } /** 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 +51,8 @@ export class CodexSessionToolCalls { }, } : {}), }); + this.open.delete(itemId); } - this.open.clear(); return updates; } } diff --git a/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-blocked-next-turn.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-blocked-next-turn.json new file mode 100644 index 000000000..e864d6725 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-blocked-next-turn.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": { + "toolCallId": "during-replay", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "during-replay", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "_meta": { + "terminal_info": { + "cwd": "/workspace", + "terminal_id": "during-replay" + } + }, + "sessionUpdate": "tool_call" + } + }, + { + "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/standard-lifecycle-v1-blocked.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-blocked.json new file mode 100644 index 000000000..c1647e2fe --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-blocked.json @@ -0,0 +1,21 @@ +[ + { + "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": "requires_action" + }, + "sessionUpdate": "subagent_update" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-cleanup.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-cleanup.json new file mode 100644 index 000000000..447485e92 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-cleanup.json @@ -0,0 +1,37 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "failed", + "_meta": { + "terminal_exit": { + "exit_code": null, + "signal": null, + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call_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/standard-lifecycle-v1-completed-replay.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-completed-replay.json new file mode 100644 index 000000000..065523c20 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-completed-replay.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": { + "toolCallId": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "completed", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "_meta": { + "terminal_info": { + "cwd": "/workspace", + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "_meta": { + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call_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/standard-lifecycle-v1-historical-only.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-historical-only.json new file mode 100644 index 000000000..e7474f767 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-historical-only.json @@ -0,0 +1,22 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Historical only", + "type": "text" + }, + "messageId": "historical-message", + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-nested-historical.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-nested-historical.json new file mode 100644 index 000000000..b64e38e86 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-nested-historical.json @@ -0,0 +1,31 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:coordinator", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:worker", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Nested historical answer", + "type": "text" + }, + "messageId": "historical-message", + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-restored-activity.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-restored-activity.json new file mode 100644 index 000000000..08da3c32c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-restored-activity.json @@ -0,0 +1,74 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Retained answer", + "type": "text" + }, + "messageId": "historical-message", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "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:subagent:worker", + "update": { + "content": { + "text": "Live after restore", + "type": "text" + }, + "messageId": "live-message", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "requires_action" + }, + "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/standard-lifecycle-v1-restored.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-restored.json new file mode 100644 index 000000000..7b5972386 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-restored.json @@ -0,0 +1,74 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "content": { + "text": "Retained answer", + "type": "text" + }, + "messageId": "historical-message", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "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:subagent:worker", + "update": { + "content": { + "text": "Live after restore", + "type": "text" + }, + "messageId": "live-message", + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "state": { + "state": "requires_action" + }, + "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/standard-lifecycle-v1-terminal.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-terminal.json new file mode 100644 index 000000000..1181d45a5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v1-terminal.json @@ -0,0 +1,41 @@ +[ + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "_meta": { + "terminal_output_delta": { + "data": "after\n", + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "completed", + "_meta": { + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "child-command" + } + }, + "sessionUpdate": "tool_call_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/standard-lifecycle-v2-blocked-next-turn.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-blocked-next-turn.json new file mode 100644 index 000000000..4b7767e40 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-blocked-next-turn.json @@ -0,0 +1,57 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "during-replay", + "command": "sleep 12", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "during-replay", + "title": "sleep 12", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "during-replay", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_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/standard-lifecycle-v2-blocked.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-blocked.json new file mode 100644 index 000000000..2db61b7b6 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-blocked.json @@ -0,0 +1,28 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "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/standard-lifecycle-v2-cleanup.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-cleanup.json new file mode 100644 index 000000000..c02126622 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-cleanup.json @@ -0,0 +1,49 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "exitStatus": { + "exitCode": null, + "signal": null + }, + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "failed", + "sessionUpdate": "tool_call_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/standard-lifecycle-v2-completed-replay.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-completed-replay.json new file mode 100644 index 000000000..ed3b2c25e --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-completed-replay.json @@ -0,0 +1,70 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "command": "sleep 12", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "title": "sleep 12", + "kind": "execute", + "status": "completed", + "content": [ + { + "terminalId": "child-command", + "type": "terminal" + } + ], + "rawInput": { + "command": "sleep 12", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "exitStatus": { + "exitCode": 0, + "signal": null + }, + "sessionUpdate": "terminal_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/standard-lifecycle-v2-historical-only.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-historical-only.json new file mode 100644 index 000000000..dddb47aab --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-historical-only.json @@ -0,0 +1,30 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": { + "text": "Historical only", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-nested-historical.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-nested-historical.json new file mode 100644 index 000000000..fa06845b7 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-nested-historical.json @@ -0,0 +1,39 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:coordinator", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:coordinator", + "update": { + "sessionId": "thread-1:subagent:worker", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": { + "text": "Nested historical answer", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-restored-activity.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-restored-activity.json new file mode 100644 index 000000000..0d6adc75d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-restored-activity.json @@ -0,0 +1,111 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "title": "Worker", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": { + "text": "Retained answer", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "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": { + "messageId": "live-message", + "content": { + "text": "Live after restore", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "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" + } + }, + { + "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/standard-lifecycle-v2-restored.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-restored.json new file mode 100644 index 000000000..e2f55544b --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-restored.json @@ -0,0 +1,111 @@ +[ + { + "sessionId": "thread-1", + "update": { + "sessionId": "thread-1:subagent:worker", + "description": "Delegate", + "capabilities": {}, + "sessionUpdate": "subagent_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "messageId": "historical-message", + "content": { + "text": "Retained answer", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "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": { + "messageId": "live-message", + "content": { + "text": "Live after restore", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "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" + } + }, + { + "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/standard-lifecycle-v2-terminal.json b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-terminal.json new file mode 100644 index 000000000..eb81ef8a8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/standard-lifecycle-v2-terminal.json @@ -0,0 +1,48 @@ +[ + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "data": "YWZ0ZXIK", + "sessionUpdate": "terminal_output_chunk" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "terminalId": "child-command", + "exitStatus": { + "exitCode": 0, + "signal": null + }, + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1:subagent:worker", + "update": { + "toolCallId": "child-command", + "status": "completed", + "sessionUpdate": "tool_call_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/standard-subagent-lifecycle.test.ts b/src/__tests__/CodexACPAgent/standard-subagent-lifecycle.test.ts new file mode 100644 index 000000000..b1915b79b --- /dev/null +++ b/src/__tests__/CodexACPAgent/standard-subagent-lifecycle.test.ts @@ -0,0 +1,348 @@ +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, turnCompleted, turnStarted, userMessageItem, type PromptSession, +} from "./v2-prompt-harness"; +import {expectConformingV2SessionUpdates} from "./v2-session-update-guard"; + +const worker = "worker"; +const childId = `${sessionId}:subagent:${worker}`; + +function onThread(event: ServerNotification, threadId = worker): ServerNotification { + return {...event, params: {...event.params, threadId}} as ServerNotification; +} + +function activity(): ServerNotification { + return itemStarted({type: "subAgentActivity", id: "spawn", kind: "started", agentThreadId: worker, agentPath: "/root/worker"}); +} + +function collab(tool: "spawnAgent" | "sendInput"): Extract { + return {type: "collabAgentToolCall", id: tool, tool, status: "completed", senderThreadId: sessionId, + receiverThreadIds: [worker], prompt: "Delegate", model: null, reasoningEffort: null, agentsStates: {}}; +} + +function message(text: string): ThreadItem { + return {type: "agentMessage", id: "historical-message", text, phase: null, memoryCitation: null, delivery: null, questions: null}; +} + +function hasText(h: PromptSession, text: string): boolean { + return h.scopedUpdates.some(entry => entry.sessionId === childId && entry.update.sessionUpdate === "agent_message_chunk" + && typeof entry.update.content === "object" && entry.update.content !== null + && "type" in entry.update.content && entry.update.content.type === "text" + && "text" in entry.update.content && entry.update.content.text === text); +} + +async function flush(h: PromptSession): Promise { + await h.waitForNotifications(); + await h.request("session/list", {}); +} + +function history(h: PromptSession, rootItems: ThreadItem[], childItems: ThreadItem[] = [], options: { + parentThreadId?: string | null; + loaded?: boolean; + onRead?: (threadId: string, includeTurns: boolean) => void | Promise; +} = {}) { + const thread = (id: string) => ({ + ...createThread(), id, sessionId, + parentThreadId: id === worker ? options.parentThreadId === undefined ? sessionId : options.parentThreadId : null, + status: id === worker && options.loaded === false ? {type: "notLoaded" as const} : {type: "idle" as const}, + }); + const resumes: string[] = []; + 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 {threadId, includeTurns = false} = params as {threadId: string; includeTurns?: boolean}; + await options.onRead?.(threadId, includeTurns); + return {thread: {...thread(threadId), turns: [{ + ...createTurn("completed", threadId === worker ? "child-active" : "history"), + itemsView: "full", items: threadId === worker ? childItems : rootItems, + }]}}; + }); + return resumes; +} + +describe.each([1, 2] as const)("ACP v%s standard child lifecycle boundaries", 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}); + return h; + } + + const load = () => h.request(version === 1 ? "session/load" : "session/resume", { + sessionId, cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + const state = () => h.scopedUpdates.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.sessionId === childId && entry.update.state && typeof entry.update.state === "object") { + return [(entry.update.state as {state: string}).state]; + } + return []; + }).at(-1); + + async function startCommand() { + await connect(); + h.emit(activity()); + h.emit(onThread(turnStarted("child-active"))); + h.emit(onThread(itemStarted(commandExecutionItem("child-command"), "child-active"))); + await flush(h); + } + + async function dump(name: string, start = 0) { + await flush(h); + const updates = h.scopedUpdates.slice(start).filter(entry => + entry.sessionId === childId || entry.update.sessionUpdate === "subagent_update"); + await expect(JSON.stringify(updates, null, 2)).toMatchFileSnapshot(`data/standard-lifecycle-v${version}-${name}.json`); + } + + it.each(["collab", "activity"] as const)("replays %s child history and reattaches its live stream", async format => { + await connect({onRequestPermission: async request => ({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}})}); + const spawn = format === "collab" ? collab("spawnAgent") : (activity().params as {item: ThreadItem}).item; + const resumes = history(h, [spawn], [message("Retained answer")]); + await load(); + expect(hasText(h, "Retained answer")).toBe(true); + expect(resumes).toEqual([sessionId, worker]); + await vi.waitFor(() => expect(state()).toBe("idle")); + h.emit(itemStarted(collab("sendInput"))); + h.emit(onThread(turnStarted("child-active"))); + h.emit(onThread(agentMessageDelta("Live after restore", "child-active", "live-message"))); + await flush(h); + expect(hasText(h, "Live after restore")).toBe(true); + expect(await h.triggerApproval("item/commandExecution/requestApproval", { + threadId: worker, turnId: "child-active", itemId: "restored-command", command: "echo hello", cwd, + commandActions: [], reason: null, availableDecisions: ["accept", "decline"], + })).toEqual({decision: "accept"}); + expect(h.transcript.some(entry => "permissionRequest" in entry && entry.permissionRequest.sessionId === childId)).toBe(true); + h.emit(onThread(turnCompleted("child-active"))); + await dump(`restored${format === "activity" ? "-activity" : ""}`); + }); + + it("does not steal a live original child when loading copied fork history", async () => { + await connect({onRequestPermission: async request => ({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}})}); + h.emit(activity()); + h.emit(onThread(turnStarted("child-active"))); + await flush(h); + const resumes = history(h, [collab("spawnAgent")], [message("Copied answer")]); + h.setCodexResponse("thread/fork", async () => ({ + ...codexResponse("thread/start") as object, thread: {...createThread(), id: "fork", sessionId: "fork", forkedFromId: sessionId}, + })); + await h.request("session/fork", {sessionId, cwd, mcpServers: []}); + await h.request(version === 1 ? "session/load" : "session/resume", { + sessionId: "fork", cwd, mcpServers: [], ...(version === 2 ? {replayFrom: {type: "start"}} : {}), + }); + expect(resumes).toEqual(["fork"]); + h.emit(onThread(agentMessageDelta("Still original", "child-active", "original-only"))); + await flush(h); + expect(hasText(h, "Still original")).toBe(true); + expect(h.scopedUpdates.some(entry => entry.sessionId === `fork:subagent:${worker}` + && entry.update.sessionUpdate === "agent_message_chunk" && "messageId" in entry.update + && entry.update.messageId === "original-only")).toBe(false); + await h.agent.cancel({sessionId: "fork"}); + expect(h.turnInterruptCalls()).toEqual([]); + }); + + it.each(["another-root", null, "not-loaded"] as const)("does not resume or confirm activity from unverified/inactive history (%s)", async owner => { + await connect(); + const resumes = history(h, [collab("spawnAgent")], [message("Historical only")], { + parentThreadId: owner === "not-loaded" ? sessionId : owner, loaded: owner !== "not-loaded", + }); + await load(); + expect(hasText(h, "Historical only")).toBe(true); + expect(resumes).toEqual([sessionId]); + await flush(h); + expect(state()).toBeUndefined(); + await dump("historical-only"); + }); + + it("receives an inactive owned child when a live sendInput wakes it later", async () => { + await connect(); + const resumes = history(h, [collab("spawnAgent")], [message("Historical only")], {loaded: false}); + await load(); + await flush(h); + expect(resumes).toEqual([sessionId]); + expect(state()).toBeUndefined(); + h.emit(itemStarted(collab("sendInput"))); + h.emit(onThread(turnStarted("child-active"))); + h.emit(onThread(agentMessageDelta("Awake", "child-active", "awake-message"))); + await flush(h); + expect(hasText(h, "Awake")).toBe(true); + expect(state()).toBe("running"); + }); + + it("requires verified runtime ancestry for nested copied children", async () => { + await connect(); + const coordinator = "coordinator"; + const resumes: string[] = []; + const items: Record = { + [sessionId]: [{...collab("spawnAgent"), receiverThreadIds: [coordinator]}], + [coordinator]: [{...collab("spawnAgent"), senderThreadId: coordinator}], + [worker]: [message("Nested historical answer")], + }; + h.setCodexResponse("thread/resume", async params => { + const id = (params as {threadId: string}).threadId; + resumes.push(id); + return {...codexResponse("thread/start") as object, thread: {...createThread(), id, sessionId}}; + }); + h.setCodexResponse("thread/read", async params => { + const id = (params as {threadId: string}).threadId; + return {thread: {...createThread(), id, sessionId, + parentThreadId: id === coordinator ? "original-root" : id === worker ? coordinator : null, + turns: [{...createTurn("completed", "history"), itemsView: "full", items: items[id] ?? []}], + }}; + }); + await load(); + expect(hasText(h, "Nested historical answer")).toBe(true); + expect(resumes).toEqual([sessionId]); + await dump("nested-historical"); + }); + + it("announces the tree before terminal cleanup during replay", async () => { + await startCommand(); + let release!: () => void; + let reading = false; + const held = new Promise(resolve => { release = resolve; }); + history(h, [], [], {onRead: async (id, full) => { + if (id === sessionId && full) { reading = true; await held; } + }}); + const start = h.scopedUpdates.length; + const pending = load(); + pending.catch(() => {}); + try { + await vi.waitFor(() => expect(reading).toBe(true)); + h.emit(onThread(turnCompleted("child-active"))); + } finally { + release(); + } + await pending; + await vi.waitFor(() => expect(state()).toBe("idle")); + const known = new Set([sessionId]); + for (const entry of h.scopedUpdates.slice(start)) { + expect(known.has(entry.sessionId), JSON.stringify(entry)).toBe(true); + if (entry.update.sessionUpdate === "subagent_update" && typeof entry.update.sessionId === "string") { + known.add(entry.update.sessionId); + } + } + await dump("cleanup", start); + }); + + it("reconciles replayed tool completion before suppressing its duplicate live event", async () => { + await startCommand(); + const completed = {...commandExecutionItem("child-command", "completed"), exitCode: 0}; + let emitted = false; + history(h, [(activity().params as {item: ThreadItem}).item], [completed], {onRead: (id, full) => { + if (id === worker && full && !emitted) { + emitted = true; + h.emit(onThread({method: "item/completed", params: {threadId: worker, turnId: "child-active", item: completed, completedAtMs: 0}})); + h.emit(onThread(turnCompleted("child-active"))); + } + }}); + const start = h.scopedUpdates.length; + await load(); + await vi.waitFor(() => expect(state()).toBe("idle")); + await flush(h); + const statuses = h.scopedUpdates.slice(start).flatMap(entry => + entry.sessionId === childId && "toolCallId" in entry.update && entry.update.toolCallId === "child-command" + && "status" in entry.update && entry.update.status ? [entry.update.status] : []); + expect(statuses.at(-1)).toBe("completed"); + expect(statuses).not.toContain("failed"); + await dump("completed-replay", start); + }); + + it.each([false, true])("retains blocked-work state before the replay response (new native turn: %s)", async newTurn => { + let answer: (() => void) | undefined; + await connect({onRequestPermission: request => new Promise(resolve => { + answer = () => resolve({outcome: {outcome: "selected", optionId: request.options[0]!.optionId}}); + })}); + h.emit(activity()); + h.emit(onThread(turnStarted("child-active"))); + await flush(h); + history(h, []); + let release!: () => void; + let ready = false; + const held = new Promise(resolve => { release = resolve; }); + const delay = async (operation: Promise): Promise => { + const response = await operation; + ready = true; + await held; + return response; + }; + 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 start = h.scopedUpdates.length; + const pending = load(); + pending.catch(() => {}); + let permission: Promise | undefined; + try { + await vi.waitFor(() => expect(ready).toBe(true)); + const turnId = newTurn ? "child-next" : "child-active"; + if (newTurn) { + h.emit(onThread(turnCompleted("child-active"))); + h.emit(onThread(turnStarted(turnId))); + h.emit(onThread(itemStarted(commandExecutionItem("during-replay"), turnId))); + } + permission = h.triggerApproval("item/commandExecution/requestApproval", { + threadId: worker, turnId, itemId: "during-replay", command: "echo hello", cwd, + commandActions: [], reason: null, availableDecisions: ["accept", "decline"], + }); + await vi.waitFor(() => expect(answer).toBeDefined()); + release(); + await pending; + await vi.waitFor(() => expect(state()).toBe("requires_action")); + await dump(`blocked${newTurn ? "-next-turn" : ""}`, start); + } finally { + release(); + answer?.(); + await permission; + await pending; + } + }); + + it("keeps command streaming and terminal completion across a new parent handler", async () => { + await startCommand(); + h.emit(onThread({method: "item/commandExecution/outputDelta", params: { + threadId: worker, turnId: "child-active", itemId: "child-command", delta: "before\n", + }})); + await flush(h); + const prompt = h.sendPrompt([{type: "text", text: "Continue parent"}]); + await vi.waitFor(() => expect(h.turnStartParams).toHaveLength(1)); + h.emit(turnStarted()); + const id = h.turnStartParams[0]!["clientUserMessageId"]; + h.emit(itemStarted(userMessageItem(typeof id === "string" ? id : null, "Continue parent"))); + h.emit(turnCompleted()); + await prompt; + await h.promptRunFinished(); + const start = h.scopedUpdates.length; + h.emit(onThread({method: "item/commandExecution/outputDelta", params: { + threadId: worker, turnId: "child-active", itemId: "child-command", delta: "after\n", + }})); + h.emit(onThread({method: "item/completed", params: {threadId: worker, turnId: "child-active", + item: {...commandExecutionItem("child-command", "completed"), aggregatedOutput: "before\nafter\n", exitCode: 0}, completedAtMs: 0}})); + h.emit(onThread(turnCompleted("child-active"))); + await flush(h); + if (version === 2) { + const updates = h.scopedUpdates.slice(start).filter(entry => entry.sessionId === childId).map(entry => entry.update); + expect(updates.some(update => update.sessionUpdate === "terminal_output_chunk")).toBe(true); + expect(updates.some(update => update.sessionUpdate === "terminal_update" && update.exitStatus + && typeof update.exitStatus === "object" && "exitCode" in update.exitStatus && update.exitStatus.exitCode === 0)).toBe(true); + } + await dump("terminal", start); + }); +}); diff --git a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts index b18881fa3..13858d834 100644 --- a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts +++ b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts @@ -414,7 +414,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-subagents.test.ts b/src/__tests__/integration/standard-subagents.test.ts index 10161d226..6c0278010 100644 --- a/src/__tests__/integration/standard-subagents.test.ts +++ b/src/__tests__/integration/standard-subagents.test.ts @@ -2,7 +2,7 @@ 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} from "node:fs"; +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"; @@ -12,7 +12,7 @@ import {functionCallResponse, startMockResponsesServer, textResponse} from "./mo describeIntegration("integration: standard subagent sessions", () => { it.each(([1, 2] as const).flatMap(version => - (["complete", "cancel", "close"] as const).map(ending => ({version, ending})), + (["complete", "cancel", "close", "terminal"] as const).map(ending => ({version, ending})), ))("replays active Codex children through ACP v$version ($ending)", async ({version, ending}) => { const mock = await startMockResponsesServer(); let delegated = false; @@ -25,6 +25,12 @@ describeIntegration("integration: standard subagent sessions", () => { if (!childRequested && agentName === "/root/worker") { childRequested = true; await childResponse; + 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"); } const model = (request as {model?: string}).model; @@ -54,7 +60,8 @@ describeIntegration("integration: standard subagent sessions", () => { }); appendFileSync(join(codexHome, "config.toml"), "\nfeatures.multi_agent = true\nfeatures.multi_agent_v2 = true\n"); const child = spawn(process.execPath, ["--import", "tsx", "src/index.ts"], { - cwd: process.cwd(), env: {...process.env, CODEX_HOME: codexHome}, stdio: ["pipe", "pipe", "pipe"], + cwd: process.cwd(), env: {...process.env, CODEX_HOME: codexHome, + ...(ending === "terminal" ? {INITIAL_AGENT_MODE: "workspace-write"} : {})}, stdio: ["pipe", "pipe", "pipe"], }); let stderr = ""; child.stderr.on("data", chunk => { stderr += chunk.toString(); }); @@ -96,6 +103,14 @@ describeIntegration("integration: standard subagent sessions", () => { 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 output = (entry: typeof updates[number]): string => { + if (entry.sessionId !== childId) return ""; + if (version === 2 && entry.update.sessionUpdate === "terminal_output_chunk") { + return Buffer.from(entry.update.data, "base64").toString("utf8"); + } + const chunk = entry.update._meta?.terminal_output_delta ?? entry.update._meta?.terminal_output; + return typeof chunk?.data === "string" ? chunk.data : ""; + }; await vi.waitFor(() => expect(updates.some(entry => state(entry)?.state === "running")).toBe(true)); await prompt; if (version === 2) { @@ -116,6 +131,28 @@ describeIntegration("integration: standard subagent sessions", () => { expect(forkChildren.length, JSON.stringify(updates)).toBeGreaterThan(0); expect(forkChildren.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)), JSON.stringify({ + stderr, updates: updates.filter(entry => entry.sessionId === childId), + outputs: mock.requests.flatMap(request => ((request as {input?: Array<{type: string; output?: unknown}>}).input ?? []) + .filter(item => item.type === "function_call_output").map(item => item.output)), + })).toBe(true), {timeout: 15_000}); + await request("session/prompt", {sessionId, prompt: [{type: "text", text: "Continue parent independently"}]}); + if (version === 2) { + await vi.waitFor(() => expect(updates.filter(entry => entry.sessionId === sessionId + && entry.update.sessionUpdate === "state_update").at(-1)?.update.state).toBe("idle")); + } + const terminalStart = updates.length; + writeFileSync(join(cwd, "release-child"), ""); + await vi.waitFor(() => { + expect(updates.slice(terminalStart).some(entry => output(entry).includes("STREAM_AFTER"))).toBe(true); + expect(updates.slice(terminalStart).some(entry => entry.sessionId === childId && (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 { if (ending === "cancel") await context.notify("session/cancel", {sessionId}); else await request("session/close", {sessionId}); @@ -126,7 +163,7 @@ describeIntegration("integration: standard subagent sessions", () => { }), `${stderr}\n${JSON.stringify(updates)}`).toBe(true), {timeout: 15_000}); } releaseChild(); - if (ending === "complete") await vi.waitFor(() => { + if (ending === "complete" || ending === "terminal") await vi.waitFor(() => { const child = updates.find(entry => entry.sessionId === sessionId && entry.update.sessionUpdate === "subagent_update"); const requests = mock.requests.map(request => { const body = request as {model?: string; tools?: Array<{name?: string; type?: string}>; input?: Array<{type?: string}>}; @@ -146,6 +183,7 @@ describeIntegration("integration: standard subagent sessions", () => { 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(); child.kill("SIGTERM"); diff --git a/src/subagents/CodexSubagentSubscriptions.ts b/src/subagents/CodexSubagentSubscriptions.ts index bd1ae1739..19408641a 100644 --- a/src/subagents/CodexSubagentSubscriptions.ts +++ b/src/subagents/CodexSubagentSubscriptions.ts @@ -4,6 +4,7 @@ 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 = ( @@ -66,6 +67,29 @@ export class CodexSubagentSubscriptions { for (const notification of notifications) session.current.dispatch(notification); } + /** History identifies a conversation; current provider parentage establishes runtime ownership. */ + 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; + // An owned but inactive child can wake later without another spawn announcement. + if (thread.status.type === "notLoaded") return null; + try { + // Rejoin an existing runtime without changing its execution context or instructions. + 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; @@ -83,20 +107,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); - session.current.observeWorkEnd?.(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) => { diff --git a/src/subagents/StandardSubagentEventRouter.ts b/src/subagents/StandardSubagentEventRouter.ts index 305fcb781..60313bd82 100644 --- a/src/subagents/StandardSubagentEventRouter.ts +++ b/src/subagents/StandardSubagentEventRouter.ts @@ -1,10 +1,11 @@ import type {ServerNotification} from "../app-server"; import type {StateUpdate} from "@agentclientprotocol/sdk/experimental/v2/unstable"; -import type {ThreadItem} from "../app-server/v2"; +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"; @@ -24,9 +25,12 @@ type Child = SubagentAssociation & { path?: string; title?: string | null; description?: string; + /** Last turn admitted for presentation; native requests can advance before buffered events. */ turnId: string | null; + foregroundTurnId: string | null; foreground: AcpV2ForegroundState; toolCalls: CodexSessionToolCalls; + streams: CodexItemStreamState; requests: AbortController; workRequests: Map; closedRequestTurns: Set; @@ -62,34 +66,45 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { private readonly capabilities = ClientCapabilities.DEFAULT, private deferLive = false, private readonly ready: Promise = Promise.resolve(), + private readonly runningTurnId: (threadId: string) => string | undefined = () => undefined, ) { this.deferredLive = new PendingNotificationBuffer(rootSessionId); } - async handle(notification: ServerNotification): Promise { + /** Admit child events before any renderer, cleanup, or other presentation side effect. */ + async prepareNotification(notification: ServerNotification): Promise { 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 (typeof threadId !== "string" || threadId === this.rootSessionId) return true; + if (this.retired) return false; const params = notification.params as {itemId?: string; item?: {id: string}}; const itemId = params.itemId ?? params.item?.id; - if (itemId !== undefined && this.replayedItems.get(threadId)?.has(itemId)) return true; - if (threadId !== this.rootSessionId && this.deferLive) { + if (itemId !== undefined && this.replayedItems.get(threadId)?.has(itemId)) return false; + if (this.deferLive) { this.deferredLive.push(notification); - return true; + return false; } - if (threadId !== this.rootSessionId && !this.children.has(threadId)) { + 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); for (const event of pending.notifications.take()) this.replayQueue.push(event); } - return true; + return false; + } + await this.ensureAnnounced(child); + return !this.retired; + } + + async handle(notification: ServerNotification): Promise { + 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; @@ -139,19 +154,23 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { && (child.turnId === null || child.turnId === progressTurnId)) { if (child.turnId === null) { child.turnId = progressTurnId; - child.foreground.invalidate(); } - await child.foreground.update({state: "running"}); + if (this.isCurrentForeground(child, progressTurnId)) { + this.beginForegroundTurn(child, progressTurnId); + await child.foreground.update({state: "running"}); + } } switch (notification.method) { case "turn/started": if (child.endedTurns.has(notification.params.turn.id)) return true; if (child.turnId !== notification.params.turn.id) { if (child.turnId !== null) this.endWorkRequests(child, child.turnId); - child.foreground.invalidate(); } child.turnId = notification.params.turn.id; - await child.foreground.start(); + if (this.isCurrentForeground(child, child.turnId)) { + this.beginForegroundTurn(child, child.turnId); + await child.foreground.start(); + } return true; case "thread/name/updated": { const title = normalizeSessionTitle(notification.params.threadName); @@ -177,6 +196,8 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { 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" ? { @@ -184,7 +205,7 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { message: turn.error ? readableServiceErrorMessage(turn.error.message) : "Subagent work failed", } : undefined, )); - child.turnId = null; + child.foregroundTurnId = null; this.onChildWorkEnded(child.sessionId); return true; } @@ -197,13 +218,14 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { } return true; case "item/started": - if (child.turnId !== null && notification.params.item.type !== "userMessage") { + if (child.turnId !== null && child.foregroundTurnId === notification.params.turnId + && notification.params.item.type !== "userMessage") { await child.foreground.startItem(notification.params.item.id); } if (notification.params.item.type === "plan") return true; break; case "item/completed": { - await child.foreground.completeItem(notification.params.item.id); + if (child.foregroundTurnId === notification.params.turnId) await child.foreground.completeItem(notification.params.item.id); const item = notification.params.item; if (item.type === "plan") { // Completed snapshots avoid replaying a partial draft over newer history. @@ -293,12 +315,17 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { const association = this.associations.register(this.rootSessionId, parentSessionId, threadId); const title = path === undefined ? undefined : nameFromAgentPath(path, "Agent"); const child: Child = { - ...association, turnId: null, + ...association, turnId: null, foregroundTurnId: null, ...(path !== undefined ? {path: normalizeAgentPath(path)} : {}), ...(title !== undefined ? {title} : {}), ...(description !== undefined ? {description} : {}), foreground: new AcpV2ForegroundState(async state => { - if (this.retired && state.state !== "unknown") return; + 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; @@ -306,6 +333,7 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { await this.session.updateSubagentState(association.sessionId, parentSessionId, state); }, "unknown"), toolCalls: new CodexSessionToolCalls(), + streams: new CodexItemStreamState(), requests: new AbortController(), workRequests: new Map(), closedRequestTurns: new Set(), @@ -343,13 +371,20 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { } async publishReplaySnapshots(): Promise { - this.snapshotting = false; - const snapshots = [...this.snapshots.values()]; - this.snapshots.clear(); - if (this.retired) return; - for (const {child, state} of snapshots) { - await this.ensureAnnounced(child); - await this.session.updateSubagentState(child.sessionId, child.parentSessionId, state); + try { + while (this.snapshots.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); + }); + } + } + } finally { + this.snapshotting = false; } } @@ -376,7 +411,9 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { 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 foreground = !this.deferLive && !this.retired && child.turnId !== null && child.turnId === turnId; + const foreground = !this.retired && !obsolete && typeof turnId === "string" + && (this.runningTurnId(child.threadId) ?? child.turnId) === turnId; + if (foreground) this.beginForegroundTurn(child, turnId); try { return await runSubagentInteraction({ sessionId, signal, @@ -397,6 +434,17 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { 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; + } + /** Withdraw requests at receipt time, before output backpressure can delay terminal rendering. */ observeWorkEnd(notification: ServerNotification): void { if (this.retired || notification.method !== "turn/completed" || notification.params.turn.status === "inProgress") return; @@ -430,6 +478,7 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { child.workRequests.clear(); await child.foreground.update({state: "unknown"}); child.turnId = null; + child.foregroundTurnId = null; } async finishOutstanding(state: SubagentState): Promise { @@ -461,9 +510,41 @@ export class StandardSubagentEventRouter implements SubagentEventRouter { return this.children.get(threadId)?.toolCalls; } - rememberReplayedItem(threadId: string, itemId: string): void { + 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 ? this.rootSessionId + : 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; + 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(); + } + } + + reconcileHistoryItem(threadId: string, item: ThreadItem, completed: boolean, turnId?: string): void { + const child = this.children.get(threadId); + if (!child) return; + child.toolCalls.restore(item, completed, turnId); + if (item.type === "commandExecution") child.streams.commands.restore(item); + if (item.type === "agentMessage") child.streams.agentMessagePhases.set(item.id, item.phase); + if (item.type === "imageView" && !completed) child.streams.emittedImageViewItems.add(item.id); + if (!completed) return; const items = this.replayedItems.get(threadId) ?? new Set(); - items.add(itemId); + items.add(item.id); this.replayedItems.set(threadId, items); } diff --git a/src/tool-calls/reporters/CommandReporter.ts b/src/tool-calls/reporters/CommandReporter.ts index cf8b242a0..1c578f5c1 100644 --- a/src/tool-calls/reporters/CommandReporter.ts +++ b/src/tool-calls/reporters/CommandReporter.ts @@ -49,6 +49,18 @@ export class CommandReporter { return startFacts(item); } + /** Replay seeds missing classification without forgetting output that already streamed live. */ + restore(item: CommandItem): void { + if (item.status === "inProgress") { + if (!this.terminalCommands.has(item.id) && !this.nonTerminalCommands.has(item.id)) this.started(item); + } else { + this.nonTerminalCommands.delete(item.id); + this.terminalCommands.delete(item.id); + this.streamedCommands.delete(item.id); + this.standardStreamedCommands.delete(item.id); + } + } + /** A chunk of command output. AIR gets the output of a read, search or list command at the end. */ outputDelta(itemId: string, delta: string): ToolFacts { if (delta.length > 0) this.standardStreamedCommands.add(itemId); From 37a742975cc5cf4ac2ab5386fe7e836a1ac95998 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 21 Oct 2026 12:30:08 +0200 Subject: [PATCH 4/5] test: avoid host sandbox dependencies in terminal lifecycle fixtures --- src/__tests__/integration/standard-subagents.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/__tests__/integration/standard-subagents.test.ts b/src/__tests__/integration/standard-subagents.test.ts index 6c0278010..dc50a31c4 100644 --- a/src/__tests__/integration/standard-subagents.test.ts +++ b/src/__tests__/integration/standard-subagents.test.ts @@ -6,6 +6,7 @@ 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"; @@ -61,7 +62,8 @@ describeIntegration("integration: standard subagent sessions", () => { appendFileSync(join(codexHome, "config.toml"), "\nfeatures.multi_agent = true\nfeatures.multi_agent_v2 = true\n"); const child = spawn(process.execPath, ["--import", "tsx", "src/index.ts"], { cwd: process.cwd(), env: {...process.env, CODEX_HOME: codexHome, - ...(ending === "terminal" ? {INITIAL_AGENT_MODE: "workspace-write"} : {})}, stdio: ["pipe", "pipe", "pipe"], + // The scripted local command tests terminal routing, not host sandbox support. + ...(ending === "terminal" ? {INITIAL_AGENT_MODE: AgentMode.AgentFullAccess.id} : {})}, stdio: ["pipe", "pipe", "pipe"], }); let stderr = ""; child.stderr.on("data", chunk => { stderr += chunk.toString(); }); From d6da9136b576603d625382290fccb9676bd86448 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 21 Oct 2026 12:44:20 +0200 Subject: [PATCH 5/5] fix: keep mock server diagnostics out of HTTP responses --- src/__tests__/MockResponsesServer.test.ts | 37 +++++++++++++++++++ .../data/mock-responses-handler-failure.json | 6 +++ .../integration/mock-responses-server.ts | 3 +- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/MockResponsesServer.test.ts create mode 100644 src/__tests__/data/mock-responses-handler-failure.json diff --git a/src/__tests__/MockResponsesServer.test.ts b/src/__tests__/MockResponsesServer.test.ts new file mode 100644 index 000000000..0c84f3e4f --- /dev/null +++ b/src/__tests__/MockResponsesServer.test.ts @@ -0,0 +1,37 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {startMockResponsesServer, type MockResponsesServer} from "./integration/mock-responses-server"; + +describe("mock Responses API handler failures", () => { + let server: MockResponsesServer | undefined; + afterEach(async () => { + await server?.close(); + server = undefined; + vi.restoreAllMocks(); + }); + + it.each([false, true])("keeps diagnostics out of HTTP responses (async: %s)", async asynchronous => { + const error = new Error("Private handler diagnostic"); + error.stack = "Error: Private handler diagnostic\n at handler (/private/internal.ts:42:1)"; + const failure = asynchronous ? error.stack : error; + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + server = await startMockResponsesServer(); + server.setResponseHandler(asynchronous + ? async () => { throw failure; } + : () => { throw failure; }); + + const response = await fetch(`${server.baseUrl}/responses`, {method: "POST", body: "{}"}); + const body = await response.text(); + expect(body).not.toContain("Private handler diagnostic"); + expect(body).not.toContain("/private/internal.ts"); + expect(log).toHaveBeenCalledWith("Mock Responses API handler failed", failure); + + server.setResponseHandler(() => "Next scripted response"); + const next = await fetch(`${server.baseUrl}/responses`, {method: "POST", body: "{}"}); + await expect(JSON.stringify({ + status: response.status, + body, + nextStatus: next.status, + nextBody: await next.text(), + }, null, 2)).toMatchFileSnapshot("data/mock-responses-handler-failure.json"); + }); +}); diff --git a/src/__tests__/data/mock-responses-handler-failure.json b/src/__tests__/data/mock-responses-handler-failure.json new file mode 100644 index 000000000..ff73db950 --- /dev/null +++ b/src/__tests__/data/mock-responses-handler-failure.json @@ -0,0 +1,6 @@ +{ + "status": 500, + "body": "mock: response handler failed", + "nextStatus": 200, + "nextBody": "Next scripted response" +} \ No newline at end of file diff --git a/src/__tests__/integration/mock-responses-server.ts b/src/__tests__/integration/mock-responses-server.ts index 18c1288c5..0a6a6409a 100644 --- a/src/__tests__/integration/mock-responses-server.ts +++ b/src/__tests__/integration/mock-responses-server.ts @@ -53,8 +53,9 @@ export async function startMockResponsesServer(): Promise { try { next = isResponsesCall ? errorResponse ?? (responseHandler ? await responseHandler(parsed) : queue.shift()) : undefined; } catch (error) { + console.error("Mock Responses API handler failed", error); res.writeHead(500); - res.end(String(error)); + res.end("mock: response handler failed"); return; } if (res.destroyed) return;