diff --git a/.agents/skills/check-acp-specification/.gitignore b/.agents/skills/check-acp-specification/.gitignore new file mode 100644 index 000000000..4ca932af3 --- /dev/null +++ b/.agents/skills/check-acp-specification/.gitignore @@ -0,0 +1 @@ +.repo diff --git a/.agents/skills/check-acp-specification/SKILL.md b/.agents/skills/check-acp-specification/SKILL.md new file mode 100644 index 000000000..f5d4e51ea --- /dev/null +++ b/.agents/skills/check-acp-specification/SKILL.md @@ -0,0 +1,41 @@ +--- +name: check-acp-specification +description: Check the current Agent Client Protocol specification, including its documentation, RFDs, Rust models, and JSON schemas. Use when ACP protocol behavior or models must be verified against the latest upstream specification. Do not use it as a Rust reference implementation. +--- + +# Check the ACP specification + +Use the official [Agent Client Protocol repository](https://github.com/agentclientprotocol/agent-client-protocol) as the source of truth for current ACP documentation, design decisions, Rust models, and JSON schemas. + +## Locate the repository + +The `.repo` file next to this `SKILL.md` is gitignored and contains one absolute path to a local clone of the specification repository. Read and trim that path before doing any upstream research. + +If `.repo` does not exist, explicitly ask the user for permission to clone the repository and for their preferred clone location. Do not clone it until permission is granted. If the user grants permission without choosing a location, clone `agent-client-protocol` beside the current ACP SDK checkout. After cloning, write the clone's absolute path to `.repo`. + +If `.repo` exists but its value does not identify a usable Git checkout, report the problem and ask the user whether to correct the path or create a clone. Do not silently replace an existing checkout. + +## Refresh before research + +Before exploring or searching the local specification checkout, update it: + +```bash +git -C "" pull --ff-only +``` + +Run this on every use of the skill, even if the checkout was used recently. If the pull fails, report the failure and do not describe the checkout as current. Do not discard local changes, reset branches, or otherwise repair the checkout without the user's authorization. + +After the pull succeeds, read the checkout's applicable `AGENTS.md` or other agent-guidance files before researching it, including more specific guidance in subdirectories you inspect. + +## Check the source of truth + +Choose the authoritative upstream evidence that matches the question: + +- Use documentation for stated public behavior and concepts. +- Use RFDs for design intent, tradeoffs, and protocol evolution. +- Use JSON schemas for wire names, shapes, required fields, optionality, nullability, and unions. +- Use Rust model definitions as another representation of protocol data types and serialization shapes. + +Trace related evidence across these sources when the question spans semantics and wire representation. Treat schemas and normative documentation as protocol contracts. Do not infer runtime behavior, lifecycle semantics, or reference implementation details from the Rust models. + +Report the upstream revision checked, cite concrete repository-relative files and lines, and distinguish confirmed specification requirements from RFD proposals or Rust model representation details. If authoritative sources disagree, surface the discrepancy instead of choosing one without explanation. diff --git a/.agents/skills/check-acp-typescript-sdk/.gitignore b/.agents/skills/check-acp-typescript-sdk/.gitignore new file mode 100644 index 000000000..4ca932af3 --- /dev/null +++ b/.agents/skills/check-acp-typescript-sdk/.gitignore @@ -0,0 +1 @@ +.repo diff --git a/.agents/skills/check-acp-typescript-sdk/SKILL.md b/.agents/skills/check-acp-typescript-sdk/SKILL.md new file mode 100644 index 000000000..52c90e1d2 --- /dev/null +++ b/.agents/skills/check-acp-typescript-sdk/SKILL.md @@ -0,0 +1,43 @@ +--- +name: check-acp-typescript-sdk +description: Check the current official Agent Client Protocol TypeScript SDK reference implementation, including runtime behavior, lifecycle semantics, error handling, tests, and API patterns. Use when behavior must be compared with the latest TypeScript reference behavior or you need to understand how to use the TypeScript SDK. Do not use it as the source of truth for protocol schemas or specification text. +--- + +# Check the ACP TypeScript SDK + +Use the official [Agent Client Protocol TypeScript SDK](https://github.com/agentclientprotocol/typescript-sdk) as the reference implementation for ACP runtime behavior. + +The TypeScript SDK is implementation evidence, not the normative protocol specification. Use the separate specification skill for current documentation, RFDs, and protocol models or schemas. If the implementation and specification disagree, report the discrepancy instead of silently treating the TypeScript behavior as the protocol contract. + +## Locate the repository + +The `.repo` file next to this `SKILL.md` is gitignored and contains one absolute path to a local clone of the TypeScript SDK repository. Read and trim that path before doing any reference-implementation research. + +If `.repo` does not exist, explicitly ask the user for permission to clone the repository and for their preferred clone location. Do not clone it until permission is granted. If the user grants permission without choosing a location, clone the repository as `acp-typescript-sdk` beside the current ACP checkout. After cloning, write the clone's absolute path to `.repo`. + +If `.repo` exists but its value does not identify a usable Git checkout, report the problem and ask the user whether to correct the path or create a clone. Do not silently replace an existing checkout. + +## Refresh before research + +Before exploring or searching the local TypeScript SDK checkout, update it: + +```bash +git -C "" pull --ff-only +``` + +Run this on every use of the skill, even if the checkout was used recently. If the pull fails, report the failure and do not describe the checkout as current. Do not discard local changes, reset branches, or otherwise repair the checkout without the user's authorization. + +After the pull succeeds, read the checkout's applicable `AGENTS.md` or other agent-guidance files before researching it, including more specific guidance in subdirectories you inspect. + +## Check the reference implementation + +Trace the complete TypeScript behavior relevant to the question rather than relying on similarly named types or isolated functions: + +- Start at the public API or protocol entry point and follow control flow through the owning runtime and transport layers. +- Read focused tests alongside the implementation to confirm observable behavior, failure handling, cancellation, cleanup, and ordering. +- Check examples when the question concerns supported API usage or lifecycle wiring. +- Check model and serialization code (e.g. Zod schemas or TypeScript type definitions) when it directly affects how the reference implementation consumes or produces protocol data. + +Compare externally observable semantics rather than translating TypeScript structure mechanically. Account for Promise, event loop, and async/await behavior, error propagation, and shutdown rules explicitly when they affect parity. + +Report the upstream revision checked and cite concrete repository-relative files and lines. Distinguish behavior demonstrated by code or tests from interpretation, and note any relevant version constraints or untested paths. diff --git a/.claude/skills/run-codex/SKILL.md b/.agents/skills/run-codex/SKILL.md similarity index 100% rename from .claude/skills/run-codex/SKILL.md rename to .agents/skills/run-codex/SKILL.md diff --git a/.claude/skills/run-codex/scripts/run-codex-test.ts b/.agents/skills/run-codex/scripts/run-codex-test.ts similarity index 100% rename from .claude/skills/run-codex/scripts/run-codex-test.ts rename to .agents/skills/run-codex/scripts/run-codex-test.ts diff --git a/.claude/agents b/.claude/agents new file mode 120000 index 000000000..4c8a5fc93 --- /dev/null +++ b/.claude/agents @@ -0,0 +1 @@ +../.agents/agents \ No newline at end of file diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d113aceb0..996ee796b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ - When adding env/config knobs, document them in `readme-dev.md`. - When updating discriminated-union/event `switch` statements, do not add a trailing fallback like `return null` only to satisfy TypeScript. - Handle each variant with an explicit `case`; if intentionally ignored, use an explicit no-op case. +- Code comments should be concise and in plain language; avoid verbosity and overly specific detail. ## Testing Guidelines diff --git a/package-lock.json b/package-lock.json index 24af40d74..c1707d8e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.2.2", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^1.5.0", + "@agentclientprotocol/sdk": "~1.5.0", "@openai/codex": "^0.160.1", "cross-spawn": "^7.0.6", "diff": "^9.0.0", diff --git a/package.json b/package.json index 297f0a972..33c478e4b 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "vitest": "^5.0.1" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.5.0", + "@agentclientprotocol/sdk": "~1.5.0", "@openai/codex": "^0.160.1", "cross-spawn": "^7.0.6", "diff": "^9.0.0", diff --git a/readme-dev.md b/readme-dev.md index 458c263fc..9ec5354ba 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -125,3 +125,58 @@ The adapter restarts the Codex app-server when it dies (for example, killed for with the stderr of the last crash. A session that the app-server died opening twice in 30 minutes is not opened again for the rest of that time, so one session that is too large for the memory cannot take the other sessions down with it. + +### ACP v2 support + +The agent speaks both ACP v1 and the unstable ACP v2 (Draft) protocol from the same process; the +version is negotiated on the first `initialize` request and fixed for the connection's lifetime. + +The v2 chain registers: `initialize`, `session/new|list|close|delete|resume` (v2 folds `session/load` +into `session/resume` with `replayFrom`), `session/set_config_option`, `session/cancel`, +`auth/login|logout`, `session/prompt` (plus `$/cancel_request`), `session/fork`, and +`providers/list|set|disable`. Unstable extension methods on top of that: `_session/steering`, +`_session/goal` (set/pause/resume/clear), and `_session/async_task/stop`. `session/fork` drops the +v1 `modes` field (v2 removed the modes API in favor of config options), and forked sessions do not +get an `available_commands_update`, matching v1. There are no new environment/config knobs specific +to v2; it reuses the runtime environment variables above. + +#### AIR v2 client contract + +These are behaviors the AIR (JetBrains) ACP client relies on that aren't obvious from the wire +schema alone: + +- On a failed prompt, read `sessionFailure` (`_meta.jetbrains.air.sessionFailure`) and `quota` + (`_meta.quota`) from the idle `state_update` that ends the turn, not from a separate error + 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; it will rename to + `subagent_update` once the subagent RFD, spec PR #1992, lands), 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. +- The SDK client treats any `idle` state update as the stop of whichever prompt is pending, not just + one paired 1:1 with a `session/prompt` call. +- A steer (`_session/steering`) shows up as a `user_message` once it actually lands in the turn. +- On cancel, any permission or elicitation request still waiting on a client response is withdrawn + with `$/cancel_request`, not just the underlying Codex turn. +- Turns Codex starts on its own (goal auto-turns, e.g. after `session/resume` or `session/new`) are + rendered the same as prompted turns (`running` then one `idle`) but with no corresponding + `session/prompt` call — clients should not assume every `running`/`idle` pair has a matching + outbound prompt. +- After a tool call streams terminal output as `terminal_output_chunk`s, completion does not resend a + full `output` snapshot; the streamed chunks are the only copy of the output. +- The AIR tool call contract (`docs/air-extensions.md`) does not apply on v2: every v2 client gets + the standard tool call reports, so `rawInputRendering`, `planContentDelta` and `diffPatch` are not + offered there. A read, search or list command has no terminal; its output arrives as `content` + text when it completes. A file change sends one diff per file whose Git patch is built from the + Codex diff. +- Other AIR `_meta` keys (goal, message phase, mode kind, permission presentation) go only to AIR + clients, as on v1. Every v2 permission request still gets its prompt `title` and `description`. + +#### Known gaps + +- After `providers/disable("openai")`, `providers/list` still reports native OpenAI routing as the + current provider. The providers RFD says a disabled provider's `current` should be `null`; this is + left as-is and documented here rather than fixed. +- Open question for AIR: AIR's fork points match Codex item ids, but on v2 a user message's + rendered id is the client-minted `clientId` (when the client supplied one), not the Codex item id. + A fork at a user message would not resolve. This is flagged for AIR, not fixed here. diff --git a/src/ACPSessionConnection.ts b/src/ACPSessionConnection.ts index c04570cac..ce4b24af2 100644 --- a/src/ACPSessionConnection.ts +++ b/src/ACPSessionConnection.ts @@ -1,20 +1,249 @@ import * as acp from "@agentclientprotocol/sdk"; +import * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; import { type AcpSessionUpdate, asSdkSessionNotification, } from "./AcpSessionExtensions"; +import {toV2SessionUpdates} from "./AcpV2SessionUpdate"; +import {toV1RequestPermissionResponse, toV2RequestPermissionRequest} from "./AcpV2Permissions"; +import {elicitationSessionId, toV1CreateElicitationResponse, toV2CreateElicitationRequest} from "./AcpV2Elicitation"; +import {logger} from "./Logger"; export type AcpClientConnection = Pick; +export type AcpV2ClientConnection = Pick; + +/** + * The client handle of a connection the protocol router routed to the ACP v2 chain. + */ +export class AcpV2Connection { + readonly client: AcpV2ClientConnection; + private isTurnRunning: (sessionId: string) => boolean = () => true; + + constructor(client: AcpV2ClientConnection) { + this.client = client; + } + + /** + * Lets the owning server report whether a turn is still running for a session, so the + * `requires_action`/`running` bracket below is only applied around requests that actually + * interrupt foreground work: a request sent while idle must not send `requires_action` (there + * is no foreground work to block), and a request that outlives its turn must not resurrect + * `running` after the turn already went `idle` on its own. + * Defaults to always running, so callers that never wire this up keep the old behavior. + */ + setTurnRunningCheck(check: (sessionId: string) => boolean): void { + this.isTurnRunning = check; + } + + /** + * Runs `sendRequest`, bracketed with `requires_action`/`running` `state_update`s only if a + * turn is running for `sessionId` at the moment the request is about to be sent. If idle, + * `sendRequest` runs unbracketed: sending `requires_action` while there is no foreground work + * to block would contradict the state's definition, and the client is already correctly idle. + * If busy, `requires_action` is sent first, and `running` follows in `finally` only if a turn + * is still running by then -- a turn that went `idle` on its own while the request was pending + * must not have that `idle` undone. + */ + private async withStateBracket(sessionId: string, sendRequest: () => Promise): Promise { + if (!this.isTurnRunning(sessionId)) { + return sendRequest(); + } + await this.sendState(sessionId, {state: "requires_action"}); + try { + return await sendRequest(); + } finally { + if (this.isTurnRunning(sessionId)) { + await this.sendState(sessionId, {state: "running"}); + } + } + } + + /** + * Sends a session update, rendered in the v2 wire shape, through the v2 `session/update` + * binding. One update can render to several v2 updates (or none), sent in order. + */ + async updateSession(sessionId: string, update: AcpSessionUpdate): Promise { + for (const v2Update of toV2SessionUpdates(update)) { + await this.client.notify(acpV2.methods.client.session.update, {sessionId, update: v2Update}); + } + } + + /** Sends a `state_update`, which only exists on v2 and so needs no rendering. */ + async updateState(sessionId: string, state: acpV2.StateUpdate): Promise { + await this.client.notify(acpV2.methods.client.session.update, { + sessionId, + update: {sessionUpdate: "state_update", ...state}, + }); + } + + /** + * Sends the whole-message update (`content: []`) that must precede the first replayed chunk + * for a given id, clearing any content a reconnecting client already holds for it. Only + * `user_message`, `agent_message` and `agent_thought` exist as whole-message updates; there + * is no v1 shape for them, so this bypasses `toV2SessionUpdates` entirely. + */ + async startReplayMessage(sessionId: string, kind: ReplayMessageKind, messageId: string): Promise { + await this.client.notify(acpV2.methods.client.session.update, { + sessionId, + update: {sessionUpdate: kind, messageId, content: []}, + }); + } + + /** `updateState`, with send failures caught and logged instead of propagated. */ + private async sendState(sessionId: string, state: acpV2.StateUpdate): Promise { + try { + await this.updateState(sessionId, state); + } catch (error) { + logger.error(`Failed to send the '${state.state}' state for session ${sessionId}`, error); + } + } + + /** + * Sends `session/request_permission`, rendered in the v2 wire shape by + * `toV2RequestPermissionRequest`. Per the spec, brackets the request with `requires_action`/ + * `running` `state_update`s -- but only while a turn is actually running (see + * `withStateBracket`); a permission request while idle (e.g. an out-of-turn MCP elicitation's + * fallback) is sent with no state update at all. + */ + async requestPermission( + request: acp.RequestPermissionRequest, + options?: acp.SendRequestOptions, + ): Promise { + return this.withStateBracket(request.sessionId, async () => { + const response = await this.client.request( + acpV2.methods.client.session.requestPermission, + toV2RequestPermissionRequest(request), + options, + ); + return toV1RequestPermissionResponse(response); + }); + } + + /** + * Sends `elicitation/create` (wire-identical request/response between versions, per + * `AcpV2Elicitation`). Brackets it with `requires_action`/`running` `state_update`s like + * `requestPermission()` (see `withStateBracket`), but only when the elicitation is + * session-scoped: a request-scoped elicitation (e.g. device-code login, sent before any + * session exists) has no session to attach a state update to, so it is sent unbracketed. + */ + async createElicitation( + request: acp.CreateElicitationRequest, + options?: acp.SendRequestOptions, + ): Promise { + const sendRequest = async () => { + const response = await this.client.request( + acpV2.methods.client.elicitation.create, + toV2CreateElicitationRequest(request), + options, + ); + return toV1CreateElicitationResponse(response); + }; + const sessionId = elicitationSessionId(request); + if (sessionId === undefined) { + return sendRequest(); + } + return this.withStateBracket(sessionId, sendRequest); + } + + /** Sends `elicitation/complete`, wire-identical between versions. */ + async completeElicitation(notification: acp.CompleteElicitationNotification): Promise { + await this.client.notify(acpV2.methods.client.elicitation.complete, notification); + } + + /** + * A v1-typed view for code that has no v2 send path yet. `_`-prefixed extension methods + * are forwarded as-is: their payloads are the same on both versions. `session/update`, + * `session/request_permission` and the elicitation methods are rendered in the v2 shape. + * Other standard methods are rejected rather than sent in the v1 wire shape. + */ + extensionOnlyV1View(): AcpClientConnection { + const view = { + notify: (method: string, params?: unknown) => { + if (method.startsWith("_")) { + return this.client.notify(method as `_${string}`, params); + } + if (method === acp.methods.client.session.update) { + const notification = params as acp.SessionNotification; + return this.updateSession(notification.sessionId, notification.update); + } + if (method === acp.methods.client.elicitation.complete) { + return this.completeElicitation(params as acp.CompleteElicitationNotification); + } + return rejectStandardMethod(method); + }, + request: (method: string, params?: unknown, options?: acp.SendRequestOptions) => { + if (method.startsWith("_")) { + return this.client.request(method as `_${string}`, params, options); + } + if (method === acp.methods.client.session.requestPermission) { + return this.requestPermission(params as acp.RequestPermissionRequest, options); + } + if (method === acp.methods.client.elicitation.create) { + return this.createElicitation(params as acp.CreateElicitationRequest, options); + } + return rejectStandardMethod(method); + }, + } as AcpClientConnection; + v2ConnectionsByView.set(view, this); + return view; + } + + /** Lets `of()` find this connection behind a view that wraps one made by `extensionOnlyV1View()`. */ + registerView(view: AcpClientConnection): void { + v2ConnectionsByView.set(view, this); + } + + /** The v2 connection behind a view made by `extensionOnlyV1View()`, or `null` for a v1 connection. */ + static of(connection: AcpClientConnection): AcpV2Connection | null { + return v2ConnectionsByView.get(connection) ?? null; + } +} + +const v2ConnectionsByView = new WeakMap(); + +function rejectStandardMethod(method: string): Promise { + return Promise.reject( + acp.RequestError.internalError(undefined, `'${method}' is not supported on an ACP v2 connection yet`), + ); +} + +/** + * The single send point for `session/update`. Callers build v1-shaped updates; on a v2 + * connection they are rendered in the v2 wire shape by `toV2SessionUpdates`. + */ export class ACPSessionConnection { private readonly connection: AcpClientConnection; + private readonly v2Connection: AcpV2Connection | null; readonly sessionId: string; constructor(connection: AcpClientConnection, sessionId: string) { this.connection = connection; + this.v2Connection = AcpV2Connection.of(connection); this.sessionId = sessionId; } + get protocolVersion(): 1 | 2 { + return this.v2Connection ? 2 : 1; + } + + /** Reports the session's foreground-work state. v1 has no such update, so this is v2 only. */ + async updateState(state: acpV2.StateUpdate): Promise { + if (!this.v2Connection) { + throw acp.RequestError.internalError(undefined, "'state_update' does not exist in ACP v1"); + } + await this.v2Connection.updateState(this.sessionId, state); + } + + /** `startReplayMessage`, which only exists on v2 and so needs no v1 shape. */ + async startReplayMessage(kind: ReplayMessageKind, messageId: string): Promise { + if (!this.v2Connection) { + throw acp.RequestError.internalError(undefined, `'${kind}' does not exist in ACP v1`); + } + await this.v2Connection.startReplayMessage(this.sessionId, kind, messageId); + } + + /** On v2, the view renders the update in the v2 shape (`extensionOnlyV1View`). */ async update(update: UpdateSessionEvent, sessionId: string = this.sessionId) { await this.connection.notify(acp.methods.client.session.update, asSdkSessionNotification({ sessionId, @@ -24,3 +253,6 @@ export class ACPSessionConnection { } export type UpdateSessionEvent = AcpSessionUpdate; + +/** The v2 whole-message update kinds that a replayed chunk stream can restart. */ +export type ReplayMessageKind = "user_message" | "agent_message" | "agent_thought"; diff --git a/src/AcpAgentRouter.ts b/src/AcpAgentRouter.ts new file mode 100644 index 000000000..eb1c36d78 --- /dev/null +++ b/src/AcpAgentRouter.ts @@ -0,0 +1,187 @@ +import * as acp from "@agentclientprotocol/sdk"; +import * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; +import type {AgentConnectOptions, AgentConnector, AnyWireMessage} from "@agentclientprotocol/sdk/experimental/v2"; +import {z} from "zod"; +import type {CodexAcpServer} from "./CodexAcpServer"; +import {type AcpClientConnection, AcpV2Connection} from "./ACPSessionConnection"; +import packageJson from "../package.json"; +import { + GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, + SESSION_STEERING_METHOD, +} from "./AcpExtensions"; +import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; +import {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} from "./CodexHookTrust"; +import { + SESSION_ARCHIVE_METHOD, + SESSION_LIST_SUBSCRIBE_METHOD, + SESSION_LIST_UNSUBSCRIBE_METHOD, + SESSION_RENAME_METHOD, + SESSION_UNARCHIVE_METHOD, + sessionArchiveParamsParser, + sessionListSubscriptionParamsParser, + sessionRenameParamsParser, +} from "./SessionIndex"; + +const emptyExtensionParamsParser = z.preprocess( + (params) => params ?? {}, + z.object({}).passthrough() +); + +const legacySetSessionModelParamsParser = z.object({ + sessionId: z.string(), + modelId: z.string(), +}).passthrough(); + +const sessionSteerParamsParser = z.object({ + sessionId: z.string(), + prompt: z.array(z.any()), +}).passthrough(); + +const goalControlParamsParser = z.discriminatedUnion("action", [ + z.object({ + sessionId: z.string(), + action: z.literal("set"), + objective: z.string().trim().min(1), + }).passthrough(), + z.object({ + sessionId: z.string(), + action: z.enum(["pause", "resume", "clear"]), + }).passthrough(), +]); + +const asyncTaskStopParamsParser = z.object({ + sessionId: z.string().trim().min(1), + asyncTaskId: z.string().trim().min(1), +}).passthrough(); + +const hooksListParamsParser = z.object({cwd: z.string().trim().min(1)}); +const hooksTrustParamsParser = z.object({ + cwd: z.string().trim().min(1), + hooks: z.array(z.object({key: z.string().min(1), currentHash: z.string().min(1)})).min(1), +}); + +// v2 request methods whose params are all-optional, per the v2 schema: `session/list` and +// `auth/logout` have no required fields. SDK 1.5.0's built-in v2 parsers for these are plain +// `z.object`s that reject an omitted `params` with -32602, even though JSON-RPC and the v2 +// schema both allow omitting it. There is no registration-layer workaround for built-in v2 +// methods (the SDK throws if you pass a parser for one), so this wraps the v2 connector's +// inbound stream to fill in `params: {}` before the request reaches the SDK. `params: null` +// is left untouched. v1 has the same SDK bug but is intentionally left as-is. +const METHODS_WITH_OPTIONAL_PARAMS = new Set([ + acpV2.methods.agent.session.list, + acpV2.methods.agent.auth.logout, +]); + +function fillOmittedParams(item: AnyWireMessage): AnyWireMessage { + if (Array.isArray(item)) { + return item.map(fillOmittedParams) as unknown as AnyWireMessage; + } + if ( + typeof item === "object" && item !== null && + "method" in item && METHODS_WITH_OPTIONAL_PARAMS.has((item as {method: unknown}).method as string) && + !("params" in item) + ) { + return {...item, params: {}} as AnyWireMessage; + } + return item; +} + +/** Wraps a v2 `AgentConnector` to work around the SDK's rejection of omitted `params`. */ +function withOmittedParamsWorkaround(agent: AgentConnector): AgentConnector { + return { + connect(stream, options?: AgentConnectOptions) { + const normalized = stream.readable.pipeThrough( + new TransformStream({ + transform(item, controller) { + controller.enqueue(fillOmittedParams(item)); + }, + }) + ); + return agent.connect({readable: normalized, writable: stream.writable}, options); + }, + }; +} + +/** + * Builds the ACP router. It picks the v1 or v2 handler chain from the first `initialize` + * request, and only the selected chain's `onConnect` runs for the connection. + */ +export function createAcpAgentRouter( + createAgent: (connection: AcpClientConnection | AcpV2Connection) => CodexAcpServer, +): acpV2.AgentProtocolRouter { + let codexAcpServer: CodexAcpServer | null = null; + const getAgent = (): CodexAcpServer => { + if (!codexAcpServer) { + throw acp.RequestError.internalError("ACP agent is not connected"); + } + return codexAcpServer; + }; + const attachAgent = (agent: CodexAcpServer, signal: AbortSignal) => { + codexAcpServer = agent; + signal.addEventListener("abort", () => { + agent.sessionIndex.dispose(); + if (codexAcpServer === agent) { + codexAcpServer = null; + } + }); + }; + + const v1Agent = acp.agent({name: packageJson.name}) + .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.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.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)) + .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(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(SESSION_LIST_SUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.subscribeList(ctx.params)) + .onRequest(SESSION_LIST_UNSUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.unsubscribeList(ctx.params)); + + const v2Agent = acpV2.agent({name: packageJson.name}) + .onConnect((connection) => attachAgent(createAgent(new AcpV2Connection(connection.client)), connection.signal)) + .onRequest(acpV2.methods.agent.initialize, (ctx) => getAgent().initializeV2(ctx.params)) + // 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)) + // No `session/set_mode`: v2 removed the modes API; modes are config options. + .onRequest(acpV2.methods.agent.session.setConfigOption, (ctx) => getAgent().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)) + .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)); + + return acpV2.agentProtocolRouter().withV1(v1Agent).withV2(withOmittedParamsWorkaround(v2Agent)); +} diff --git a/src/AcpV2ClientCapabilities.ts b/src/AcpV2ClientCapabilities.ts new file mode 100644 index 000000000..c9cc2fbf1 --- /dev/null +++ b/src/AcpV2ClientCapabilities.ts @@ -0,0 +1,37 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; + +/** + * Maps v2 client capabilities onto the v1 shape the capability readers use, so each reader + * does not need a v2 fork. + * + * Fields that exist on v2 carry over at the same relative path: `elicitation`, `auth._meta` + * (gateway) and `_meta` (JetBrains AIR). Plan updates, compaction and notices need no client + * capability on v2, so their v1 probes (`plan`, `session.compaction`, `session.notices`) are + * always set. `subagents` stays absent. + */ +export function toV1ClientCapabilitiesView( + capabilities: acpV2.ClientCapabilities | null | undefined, +): acp.ClientCapabilities { + const view: acp.ClientCapabilities = { + plan: {}, + session: {compaction: {}, notices: {}}, + }; + if (!capabilities) { + return view; + } + if (capabilities.elicitation) { + view.elicitation = capabilities.elicitation; + } + if (capabilities.auth) { + const {terminal, _meta} = capabilities.auth; + view.auth = { + ...(terminal != null ? {terminal: true} : {}), + ...(_meta != null ? {_meta} : {}), + }; + } + if (capabilities._meta != null) { + view._meta = capabilities._meta; + } + return view; +} diff --git a/src/AcpV2ConfigOptions.ts b/src/AcpV2ConfigOptions.ts new file mode 100644 index 000000000..0ca0acd33 --- /dev/null +++ b/src/AcpV2ConfigOptions.ts @@ -0,0 +1,60 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; + +/** + * Renders a config option built by the shared (v1-typed) builders in the ACP v2 wire shape. + * + * v2 renames the option's `id` to `configId` and a select group's `group` to `groupId`; all + * values are unchanged. + */ +export function toV2ConfigOption(option: acp.SessionConfigOption): acpV2.SessionConfigOption { + switch (option.type) { + case "boolean": { + const {id, ...rest} = option; + return {configId: id, ...rest}; + } + case "select": { + const {id, options, ...rest} = option; + return {configId: id, ...rest, options: toV2SelectOptions(options)}; + } + } +} + +export function toV2ConfigOptions(options: Array): Array { + return options.map(toV2ConfigOption); +} + +function toV2SelectOptions(options: acp.SessionConfigSelectOptions): acpV2.SessionConfigSelectOptions { + if (!isGrouped(options)) { + return options; + } + return options.map(({group, ...rest}) => ({groupId: group, ...rest})); +} + +function isGrouped(options: acp.SessionConfigSelectOptions): options is Array { + const first = options[0]; + return first !== undefined && "group" in first; +} + +/** + * Maps a v2 `session/set_config_option` request onto the v1 request shape the shared dispatcher + * takes. v2 tags every value with `type`; only the `id` and `boolean` kinds exist for codex-acp's + * options, so any other kind, or a value that doesn't match its kind, is invalid. + */ +export function toV1SetSessionConfigOptionRequest( + params: acpV2.SetSessionConfigOptionRequest, +): acp.SetSessionConfigOptionRequest { + const base = { + sessionId: params.sessionId, + configId: params.configId, + ...(params._meta !== undefined ? {_meta: params._meta} : {}), + }; + const value: unknown = params.value; + if (params.type === "id" && typeof value === "string") { + return {...base, value}; + } + if (params.type === "boolean" && typeof value === "boolean") { + return {...base, type: "boolean", value}; + } + throw acp.RequestError.invalidParams(); +} diff --git a/src/AcpV2Elicitation.ts b/src/AcpV2Elicitation.ts new file mode 100644 index 000000000..5fc977200 --- /dev/null +++ b/src/AcpV2Elicitation.ts @@ -0,0 +1,23 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; + +/** + * `elicitation/create`'s request/response, and `elicitation/complete`'s notification, are + * wire-identical between v1 and v2 (same fields, same method names) -- unlike + * `session/request_permission`, there is no shape to render, only the type import differs per + * version. These are identity casts kept as named conversion points so the rest of the codebase + * never has to reach into the v2 namespace directly. + */ +export function toV2CreateElicitationRequest(request: acp.CreateElicitationRequest): acpV2.CreateElicitationRequest { + return request as unknown as acpV2.CreateElicitationRequest; +} + +export function toV1CreateElicitationResponse(response: acpV2.CreateElicitationResponse): acp.CreateElicitationResponse { + return response as unknown as acp.CreateElicitationResponse; +} + +/** A session-scoped elicitation carries `sessionId`; a request-scoped one carries `requestId` instead. */ +export function elicitationSessionId(request: acp.CreateElicitationRequest): string | undefined { + const sessionId = (request as {sessionId?: unknown}).sessionId; + return typeof sessionId === "string" ? sessionId : undefined; +} diff --git a/src/AcpV2Permissions.ts b/src/AcpV2Permissions.ts new file mode 100644 index 000000000..dde06dcf4 --- /dev/null +++ b/src/AcpV2Permissions.ts @@ -0,0 +1,51 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; +import {toV2ToolCallContent} from "./AcpV2SessionUpdate"; +import {PERMISSION_PROMPT_META_KEY, readPermissionMeta} from "./permissions/metadata"; + +/** + * Renders a v1-shaped `session/request_permission` request in the ACP v2 wire shape: v1's bare + * `toolCall` becomes v2's `{title (required), description?, subject}`. + * + * v1 has no dedicated prompt-copy field, so call sites either carry the prompt copy in the private + * `_meta.permission_prompt` key (`requestPermissionMeta`) alongside a genuine `toolCall`, or (plan + * review) have no such `_meta` and instead set the prompt copy directly as `toolCall.title`. The + * private key wins when present and is not sent; otherwise `toolCall.title` is promoted to the + * top-level `title` and dropped from the subject's tool call, since it was the only title + * available and isn't genuine tool-call state. + */ +export function toV2RequestPermissionRequest(request: acp.RequestPermissionRequest): acpV2.RequestPermissionRequest { + const meta = readPermissionMeta(request._meta); + const {title: toolCallTitle, ...toolCallWithoutTitle} = request.toolCall; + const title = meta?.title ?? toolCallTitle; + if (!title) { + throw acp.RequestError.internalError( + undefined, + "'session/request_permission' request has no title to send on ACP v2", + ); + } + const {content, ...toolCallFields} = meta ? request.toolCall : toolCallWithoutTitle; + const toolCall: acpV2.ToolCallUpdate = { + ...toolCallFields, + ...(content !== undefined ? {content: content && content.map(toV2ToolCallContent)} : {}), + }; + const {[PERMISSION_PROMPT_META_KEY]: _prompt, ...otherMeta} = request._meta ?? {}; + return { + sessionId: request.sessionId, + title, + ...(meta?.description ? {description: meta.description} : {}), + subject: {type: "tool_call", toolCall}, + options: request.options, + ...(Object.keys(otherMeta).length > 0 ? {_meta: otherMeta} : {}), + }; +} + +/** + * v2's `RequestPermissionResponse` is wire-identical to v1's (same `outcome`/`_meta` fields), + * except v2's `outcome` is an open union: a client may answer with a custom/future value instead + * of `"cancelled"`/`"selected"`. Readers must check for `"selected"` explicitly rather than treat + * anything other than `"cancelled"` as selected (ACP-ENUM-203). + */ +export function toV1RequestPermissionResponse(response: acpV2.RequestPermissionResponse): acp.RequestPermissionResponse { + return response as unknown as acp.RequestPermissionResponse; +} diff --git a/src/AcpV2Prompt.ts b/src/AcpV2Prompt.ts new file mode 100644 index 000000000..72c94736b --- /dev/null +++ b/src/AcpV2Prompt.ts @@ -0,0 +1,90 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; +import type {ServerNotification} from "./app-server"; + +/** + * Lets a prompt report when Codex records its user message. On v2 that moment, not the turn + * start, is when the prompt counts as inserted. + */ +export type UserMessageInsertion = { + /** Sent as `clientUserMessageId` on `turn/start`; Codex echoes it as `userMessage.clientId`. */ + clientUserMessageId: string; + onInserted: () => Promise; + /** + * Shows a synthetic prompt codex-acp starts itself (the plan-implementation follow-up turn, + * a `/goal` continuation) as a live `user_message`, once its own minted `clientUserMessageId` + * is echoed back. These turns run inside the original prompt's running…idle pair, so this + * only emits the chunks: no response is resolved and no `state_update` is sent. + */ + onSyntheticInserted: (clientUserMessageId: string, prompt: acp.ContentBlock[]) => Promise; + /** + * Fired synchronously once, from the turn-start response, when Codex steers this prompt into + * a turn that was already running and unowned (its `running` went out before this prompt + * existed). Lets the caller skip sending a duplicate `running` and still close the turn with + * one `idle` if the steered input never lands. + */ + onTurnAdopted?: () => void; + /** + * Fired synchronously each time a turn starts (or restarts) for this prompt, with the turn's + * ids. Lets a pending `$/cancel_request` interrupt the right turn before insertion. + */ + onTurnStarted?: (turn: {threadId: string, turnId: string}) => void; +}; + +/** + * Whether the notification is Codex recording the user message sent with `clientUserMessageId`. + * Codex records the message only after pre-turn work (compaction, MCP startup, hooks), and a + * blocking hook can skip it. Match on the id: steered or Codex-originated user messages share + * the turn. + */ +export function isInsertedUserMessage( + event: ServerNotification, + threadId: string, + clientUserMessageId: string, +): boolean { + return (event.method === "item/started" || event.method === "item/completed") + && event.params.threadId === threadId + && event.params.item.type === "userMessage" + && event.params.item.clientId === clientUserMessageId; +} + +const V1_CONTENT_BLOCK_TYPES = new Set(["text", "image", "audio", "resource_link", "resource"]); + +/** + * v2 content blocks are the v1 ones plus reserved custom/future types, which codex-acp does not + * handle and rejects. + */ +export function toV1PromptRequest(params: acpV2.PromptRequest): acp.PromptRequest { + for (const block of params.prompt) { + if (!V1_CONTENT_BLOCK_TYPES.has(block.type)) { + throw acp.RequestError.invalidParams(undefined, `Unsupported content block type: ${block.type}`); + } + } + return params as acp.PromptRequest; +} + +/** + * The `idle` state that ends a v2 turn carries what v1 answers the prompt with: the stop reason, + * the token usage and the `_meta` (quota, and a typed session failure for clients that + * negotiated it). + */ +export function toV2IdleState(response: acp.PromptResponse): acpV2.StateUpdate { + return { + state: "idle", + stopReason: response.stopReason, + ...(response.usage != null ? {usage: response.usage} : {}), + ...(response._meta != null ? {_meta: response._meta} : {}), + }; +} + +/** + * The agent message that tells a v2 client why its prompt failed after it was inserted, when + * there is no longer a request to answer with an error. + */ +export function postInsertionFailureText(error: unknown, commandName?: string): string { + const message = error instanceof Error && error.message.length > 0 ? error.message : null; + if (commandName !== undefined) { + return message === null ? `The '/${commandName}' command failed.` : `The '/${commandName}' command failed: ${message}`; + } + return message ?? "The prompt failed."; +} diff --git a/src/AcpV2SessionUpdate.ts b/src/AcpV2SessionUpdate.ts new file mode 100644 index 000000000..e01cdf137 --- /dev/null +++ b/src/AcpV2SessionUpdate.ts @@ -0,0 +1,280 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; +import {randomUUID} from "node:crypto"; +import * as path from "node:path"; +import type {AcpSessionUpdate} from "./AcpSessionExtensions"; +import {toV2ConfigOptions} from "./AcpV2ConfigOptions"; +import {stripShellPrefix} from "./CommandUtils"; + +/** + * `planId` of the structured (tool-driven) plan on v2. A session has at most one such plan and + * every update replaces it, so a constant id is stable for the whole session. + */ +export const STRUCTURED_PLAN_ID = "codex-structured-plan"; + +/** + * Renders an internal (v1-shaped) session update as the ACP v2 updates to send, in order. + * + * v1 carries agent-owned command output in private tool call `_meta` keys. On v2 these become + * standard `terminal_update`/`terminal_output_chunk` updates sent before the rest of the tool + * call update, and never reach the client as `_meta`. An update that only carried terminal + * output renders to no tool call update at all. + */ +export function toV2SessionUpdates(update: AcpSessionUpdate): acpV2.SessionUpdate[] { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return [toV2SessionUpdate(update)]; + } + const {_meta, ...toolCall} = update; + if (_meta == null || !TERMINAL_META_KEYS.some(key => key in _meta)) { + return [toV2SessionUpdate(update)]; + } + const {terminal_info, terminal_output, terminal_output_delta, terminal_exit, ...otherMeta} = _meta; + const terminalUpdates = toV2TerminalUpdates( + {info: terminal_info, output: terminal_output ?? terminal_output_delta, exit: terminal_exit}, + toolCall.rawInput, + ); + const {sessionUpdate: _tag, toolCallId: _id, ...toolCallFields} = toolCall; + if (Object.keys(toolCallFields).length === 0 && Object.keys(otherMeta).length === 0) { + return terminalUpdates; + } + return [ + ...terminalUpdates, + toV2SessionUpdate({...toolCall, ...(Object.keys(otherMeta).length > 0 ? {_meta: otherMeta} : {})}), + ]; +} + +/** + * Renders an internal (v1-shaped) session update in the ACP v2 wire shape. + * + * The v2 SDK sends `session/update` params as-is, without validation, so a variant with no v2 + * rendering yet throws here instead of reaching a v2 client in the v1 shape. + */ +export function toV2SessionUpdate(update: AcpSessionUpdate): acpV2.SessionUpdate { + switch (update.sessionUpdate) { + // Same wire shape on v1 and v2. + case "plan_update": + case "plan_removed": + case "session_info_update": + case "usage_update": + case "notice": + case "compaction_update": + case "compaction_summary_chunk": + return update; + case "config_option_update": + return {...update, configOptions: toV2ConfigOptions(update.configOptions)}; + case "plan": { + const {entries, _meta} = update; + return { + sessionUpdate: "plan_update", + plan: {type: "items", planId: STRUCTURED_PLAN_ID, entries}, + ...(_meta != null ? {_meta} : {}), + }; + } + // v2 command input is a tagged union; v1 input is the untagged text form. + case "available_commands_update": + return { + ...update, + availableCommands: update.availableCommands.map((command) => ({ + ...command, + ...(command.input != null ? {input: {...command.input, type: "text" as const}} : {}), + })), + }; + // v2 removed the modes API. codex-acp never emits this (mode changes go out as + // `config_option_update`), so there is no v2 rendering to give it. + case "current_mode_update": + throw acp.RequestError.internalError( + undefined, + "'current_mode_update' session update does not exist in ACP v2", + ); + // v2 message chunks require `messageId`. A user message without one would not match the + // `messageId` of its prompt response, so it is rejected rather than given a fresh id. + case "user_message_chunk": { + const {messageId} = update; + if (messageId == null) { + throw acp.RequestError.internalError( + undefined, + "'user_message_chunk' session update without a messageId is not supported on an ACP v2 connection", + ); + } + return {...update, messageId}; + } + // Agent chunks without an id are one-off notices sent as a single chunk (warnings, errors, + // slash-command replies), so each gets a fresh id and starts its own message. Replayed + // history must carry its own ids instead, since they have to be the same on every replay. + case "agent_message_chunk": + case "agent_thought_chunk": + return {...update, messageId: update.messageId || randomUUID()}; + // v2 has no separate create: the first `tool_call_update` for an id creates the tool call. + case "tool_call": + case "tool_call_update": { + const {content, ...rest} = update; + return { + ...rest, + sessionUpdate: "tool_call_update", + ...(content !== undefined ? {content: content && content.map(toV2ToolCallContent)} : {}), + }; + } + // Subagent RFD (spec PR #1992, unmerged) uses a single upsert-style update. Sent under a + // `_`-prefixed tag because the RFD's `subagent_update` isn't in the v2 unstable schema yet; + // rename to `subagent_update` once it lands there. The AIR `nativeSubagentSessions` gate + // already ran upstream (whatever created this update), so no gate check is needed here. + case "subagent_spawned": { + const {subagentSessionId, name, task, _meta} = update; + return { + sessionUpdate: "_subagent_update", + subagentSessionId, + name, + task, + capabilities: {}, + ...(_meta != null ? {_meta} : {}), + }; + } + case "subagent_state_update": { + const {subagentSessionId, state, _meta} = update; + return { + sessionUpdate: "_subagent_update", + subagentSessionId, + state, + ...(_meta != null ? {_meta} : {}), + }; + } + // No upstream v2 design exists for async tasks (AIR-private extension); same payload as v1 + // under a `_`-prefixed tag. Gated on AIR `asyncTasks` upstream, same as above. + case "async_task_spawned": + return {...update, sessionUpdate: "_async_task_spawned"}; + case "async_task_state_update": + return {...update, sessionUpdate: "_async_task_state_update"}; + // Declared as a type but never emitted; no v2 rendering to give it yet. + case "async_task_progress": + throw acp.RequestError.internalError( + undefined, + `'${update.sessionUpdate}' session update is not supported on an ACP v2 connection yet`, + ); + } +} + +/** Renders a v1 tool call content item in the ACP v2 shape. Shared with permission requests. */ +export function toV2ToolCallContent(content: acp.ToolCallContent): acpV2.ToolCallContent { + switch (content.type) { + case "content": + return content; + // Same shape on v2; the terminal itself is sent by `toV2SessionUpdates`. + case "terminal": + return content; + case "diff": + return {type: "diff", ...toV2Diff(content)}; + } +} + +/** + * Private diff `_meta` key with the Git patch of a file change and the path before the change + * (`FileChangeReporter`). v1 diffs only name the path after the change. + */ +export const DIFF_GIT_PATCH_META_KEY = "diff_git_patch"; + +/** v2 diffs describe the change in `changes[]` and carry a Git patch instead of `oldText`/`newText`. */ +function toV2Diff(content: acp.Diff): acpV2.Diff { + const {[DIFF_GIT_PATCH_META_KEY]: patchMeta, ...meta} = content._meta ?? {}; + const {oldPath, text} = diffGitPatch(patchMeta); + const kind = meta["kind"]; + const path = content.path; + let change: acpV2.DiffChange; + switch (kind) { + case "add": + change = {operation: "add", path}; + break; + case "delete": + change = {operation: "delete", path}; + break; + case "update": + change = oldPath === path ? {operation: "modify", path} : {operation: "move", oldPath, path}; + break; + default: + throw acp.RequestError.internalError(undefined, `Diff content without a known 'kind': ${String(kind)}`); + } + return { + changes: [change], + patch: {format: "git_patch", text}, + ...(Object.keys(meta).length > 0 ? {_meta: meta} : {}), + }; +} + +function diffGitPatch(value: unknown): {oldPath: string; text: string} { + const patch = value as {oldPath?: unknown; text?: unknown} | null | undefined; + if (typeof patch?.oldPath !== "string" || typeof patch.text !== "string") { + throw acp.RequestError.internalError(undefined, `Malformed '${DIFF_GIT_PATCH_META_KEY}' diff metadata`); + } + return {oldPath: patch.oldPath, text: patch.text}; +} + +/** Private v1 `_meta` keys that carry agent-owned command output on a tool call update. */ +const TERMINAL_META_KEYS = ["terminal_info", "terminal_output", "terminal_output_delta", "terminal_exit"]; + +type TerminalMeta = {info: unknown; output: unknown; exit: unknown}; + +type TerminalMetaRecord = { + terminal_id: string; + cwd?: unknown; + data?: unknown; + exit_code?: unknown; + signal?: unknown; +}; + +/** + * `terminal_info` announces the terminal (its `cwd`; the command is the tool call's + * `rawInput.command` without the shell wrapper, the same text as the tool call title), `terminal_output`/`terminal_output_delta` carry output to append, and + * `terminal_exit` marks completion. Output sent together with the exit is the command's whole + * output (it is only sent when none was streamed), so it becomes the final snapshot. + */ +function toV2TerminalUpdates(meta: TerminalMeta, rawInput: unknown): acpV2.SessionUpdate[] { + const info = meta.info == null ? null : terminalMetaRecord(meta.info, "terminal_info"); + const output = meta.output == null ? null : terminalMetaRecord(meta.output, "terminal_output"); + const exit = meta.exit == null ? null : terminalMetaRecord(meta.exit, "terminal_exit"); + const terminalId = (info ?? output ?? exit)!.terminal_id; + + const terminalUpdate: acpV2.TerminalUpdate = {terminalId}; + if (info) { + const command = (rawInput as {command?: unknown} | null | undefined)?.command; + if (typeof command === "string") { + terminalUpdate.command = stripShellPrefix(command); + } + // v2 requires an absolute `cwd`; history fallbacks may not know it. + if (typeof info.cwd === "string" && path.isAbsolute(info.cwd)) { + terminalUpdate.cwd = info.cwd; + } + } + const data = output ? base64(output.data, "terminal_output") : null; + if (exit) { + if (data !== null) { + terminalUpdate.output = {data}; + } + terminalUpdate.exitStatus = { + exitCode: typeof exit.exit_code === "number" ? exit.exit_code : null, + signal: typeof exit.signal === "string" ? exit.signal : null, + }; + } + + const updates: acpV2.SessionUpdate[] = []; + if (info || exit) { + updates.push({sessionUpdate: "terminal_update", ...terminalUpdate}); + } + if (data !== null && !exit) { + updates.push({sessionUpdate: "terminal_output_chunk", terminalId, data}); + } + return updates; +} + +function terminalMetaRecord(value: unknown, key: string): TerminalMetaRecord { + if (typeof value !== "object" || value === null || typeof (value as {terminal_id?: unknown}).terminal_id !== "string") { + throw acp.RequestError.internalError(undefined, `Malformed '${key}' tool call metadata`); + } + return value as TerminalMetaRecord; +} + +/** v2 terminal bytes are base64; Codex reports output as already-decoded text. */ +function base64(data: unknown, key: string): string { + if (typeof data !== "string") { + throw acp.RequestError.internalError(undefined, `Malformed '${key}' tool call metadata`); + } + return Buffer.from(data, "utf8").toString("base64"); +} diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f8fc9380e..287dd1138 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -8,7 +8,7 @@ import { } from "./CodexAuthMethod"; import type {EmbeddedResourceResource} from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk"; -import {type McpServer, RequestError} from "@agentclientprotocol/sdk"; +import {RequestError} from "@agentclientprotocol/sdk"; import type { ApprovalHandler, CodexAppServerClient, @@ -31,6 +31,7 @@ import {logger} from "./Logger"; import {isAccountReadAuthFailureError, isAccountReadUnavailableError} from "./CodexThreadErrors"; import {sanitizeMcpServerName} from "./McpServerName"; import {listedSessionTitle} from "./SessionTitle"; +import {type AcpMcpServer, type WithAcpMcpServers, getMcpServerName, normalizeMcpServer, toCodexMcpServerConfig} from "./McpServerConfig"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, @@ -52,6 +53,7 @@ import type { ThreadResumeParams, ThreadSourceKind, ThreadItem, + ThreadItemEntry, TurnCompletedNotification, TurnSteerResponse, UserInput, @@ -186,7 +188,7 @@ export class CodexAcpClient { name: `${packageJson.name}`, title: "Codex ACP", version: `${packageJson.version}` }; - async initialize(request: acp.InitializeRequest): Promise { + async initialize(request: Pick): Promise { const response = await this.codexClient.initialize({ capabilities: { experimentalApi: true, @@ -671,7 +673,7 @@ export class CodexAcpClient { } } - async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise { + async resumeSession(request: WithAcpMcpServers, onSubscribed?: () => void): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); @@ -700,7 +702,7 @@ export class CodexAcpClient { } async forkSession( - request: acp.ForkSessionRequest, + request: WithAcpMcpServers, releaseFailedFork: (threadId: string) => Promise = threadId => this.closeSession(threadId), ): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); @@ -718,7 +720,7 @@ export class CodexAcpClient { }); } - async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { + async loadSession(request: WithAcpMcpServers, onSubscribed?: () => void): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); @@ -734,16 +736,24 @@ export class CodexAcpClient { // Resume cursors bound durable history; later turns arrive through live events. // A null paginated cursor means there was no durable history at resume time. let thread: Thread = {...response.thread, turns: []}; - let history: AsyncIterable = noItems(); + let history: AsyncIterable = noItems(); if (response.materialized && response.thread.historyMode === "paginated") { if (response.itemsBackwardsCursor !== null) { - history = this.codexClient.threadItemPages(response.thread.id, {lastItemCursor: response.itemsBackwardsCursor}); + history = this.codexClient.threadItemEntryPages( + response.thread.id, + {lastItemCursor: response.itemsBackwardsCursor}, + ); } } else if (response.materialized) { // A legacy store reads the whole history in one request. const legacy = (await this.codexClient.threadReadWithHistory(response.thread.id)).thread; thread = {...legacy, turns: []}; - history = oneItemPage(legacy.turns.flatMap(turn => turn.items)); + history = oneItemPage(legacy.turns.flatMap(turn => turn.items.map(item => ({ + turnId: turn.id, + item, + startedAtMs: null, + completedAtMs: null, + })))); } const codexModels = await this.fetchAvailableModels(); const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); @@ -788,12 +798,12 @@ export class CodexAcpClient { return null; } - async newSession(request: acp.NewSessionRequest): Promise { + async newSession(request: WithAcpMcpServers): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); const developerInstructions = airCustomInstructions(request._meta); await this.refreshSkills(request.cwd, additionalDirectories); - const sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers); + const sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []); const response = await this.codexClient.threadStart({ config: sessionConfig.config, modelProvider: this.getModelProvider(), @@ -827,6 +837,18 @@ export class CodexAcpClient { } } + /** + * Drops a session's notification handler without unsubscribing on the + * app-server: for a baseline tracker registered before `thread/resume` + * (to catch a Codex-initiated turn that starts before setup finishes) + * whose resume/load never actually subscribed the connection, so there + * is nothing to unsubscribe there. + */ + discardSessionSubscription(sessionId: string): void { + this.codexClient.clearThreadHandlers(sessionId); + this.subagents.clear(sessionId); + } + async deleteSession(sessionId: string): Promise { try { await this.codexClient.threadArchive({threadId: sessionId}); @@ -853,19 +875,21 @@ export class CodexAcpClient { sessionId: string, target: ReviewTarget, onTurnStarted?: (turnId: string, threadId: string) => void, + onAccepted?: () => void, ): Promise { return await this.codexClient.runReview({ threadId: sessionId, target, delivery: "inline", - }, onTurnStarted); + }, onTurnStarted, onAccepted); } async runCompact( sessionId: string, onTurnStarted?: (turnId: string) => void, + onAccepted?: () => void, ): Promise { - const completed = await this.codexClient.runCompact({threadId: sessionId}, onTurnStarted); + const completed = await this.codexClient.runCompact({threadId: sessionId}, onTurnStarted, onAccepted); return completed.method === "turn/completed" ? completed.params : undefined; } @@ -879,16 +903,17 @@ export class CodexAcpClient { objective: string, onTurnStarted?: (turnId: string) => void, onGoalSet?: (goal: ThreadGoal) => void, + onAccepted?: () => void, ): Promise { const params = { threadId: sessionId, objective, status: "active", } as const; - if (onGoalSet === undefined) { + if (onGoalSet === undefined && onAccepted === undefined) { return await this.codexClient.runGoalSet(params, onTurnStarted); } - return await this.codexClient.runGoalSet(params, onTurnStarted, undefined, onGoalSet); + return await this.codexClient.runGoalSet(params, onTurnStarted, undefined, onGoalSet, onAccepted); } async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { @@ -909,15 +934,16 @@ export class CodexAcpClient { sessionId: string, onTurnStarted?: (turnId: string) => void, onGoalSet?: (goal: ThreadGoal) => void, + onAccepted?: () => void, ): Promise { const params = { threadId: sessionId, status: "active", } as const; - if (onGoalSet === undefined) { + if (onGoalSet === undefined && onAccepted === undefined) { return await this.codexClient.runGoalSet(params, onTurnStarted); } - return await this.codexClient.runGoalSet(params, onTurnStarted, undefined, onGoalSet); + return await this.codexClient.runGoalSet(params, onTurnStarted, undefined, onGoalSet, onAccepted); } async clearGoal(sessionId: string): Promise { @@ -939,7 +965,7 @@ export class CodexAcpClient { private async createSessionConfig( projectPath: string, additionalDirectories: string[], - mcpServers: Array + mcpServers: Array ): Promise { const sessionRoots = [projectPath, ...additionalDirectories]; const activeProvider = this.gatewayConfig @@ -967,7 +993,7 @@ export class CodexAcpClient { } const requestedServers = mcpServers.map(mcp => ({ - name: sanitizeMcpServerName(mcp.name), + name: sanitizeMcpServerName(getMcpServerName(mcp)), server: mcp, })); let serversToConfigure = requestedServers; @@ -992,7 +1018,7 @@ export class CodexAcpClient { return { config: { ...configWithWorkspaceRoots, - "mcp_servers": Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])), + "mcp_servers": Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, toCodexMcpServerConfig(normalizeMcpServer(mcp.server))])), }, skippedMcpServers, }; @@ -1038,30 +1064,6 @@ export class CodexAcpClient { } } - /** - * Create a codex config entry for MCP server - */ - private createMcpSeverConfig(mcpServer: McpServer): JsonObject { - if ("type" in mcpServer) { - switch (mcpServer.type) { - case "acp": - throw RequestError.invalidRequest("Codex doesn't support MCP ACP transport protocol") - case "sse": - throw RequestError.invalidRequest("Codex doesn't support MCP SSE transport protocol") - case "http": - return { - "url": mcpServer.url, - "http_headers": Object.fromEntries(mcpServer.headers.map(h => [h.name, h.value])), - } - } - } - return { - "command": mcpServer.command, - "args": mcpServer.args, - "env": Object.fromEntries(mcpServer.env.map(env => [env.name, env.value])), - } - } - /** * Resolves a ModelId using the provided ID and reasoning effort. * Falls back to model defaults if parameters are missing or unsupported. @@ -1159,6 +1161,7 @@ export class CodexAcpClient { additionalDirectories: string[], onTurnStarted?: (turnId: string) => void, shouldCancel?: () => boolean, + clientUserMessageId?: string, ): Promise { const input = buildPromptItems(request.prompt); const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion @@ -1176,6 +1179,7 @@ export class CodexAcpClient { effort: effort, model: modelId.model, serviceTier: serviceTier, + ...(clientUserMessageId !== undefined ? {clientUserMessageId} : {}), }, onTurnStarted); } @@ -1329,11 +1333,12 @@ export class CodexAcpClient { }); } - async steerTurn(params: { threadId: string, turnId: string, prompt: acp.ContentBlock[] }): Promise { + async steerTurn(params: { threadId: string, turnId: string, prompt: acp.ContentBlock[], clientUserMessageId: string }): Promise { return await this.codexClient.turnSteer({ threadId: params.threadId, expectedTurnId: params.turnId, input: buildPromptItems(params.prompt), + clientUserMessageId: params.clientUserMessageId, }); } @@ -1565,8 +1570,8 @@ function mergeGatewayConfig(config: JsonObject, gatewayConfig: GatewayConfig | n } } -async function* noItems(): AsyncGenerator {} +async function* noItems(): AsyncGenerator {} -async function* oneItemPage(items: ThreadItem[]): AsyncGenerator { +async function* oneItemPage(items: T[]): AsyncGenerator { if (items.length > 0) yield items; } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index d3f90a8bb..fa0133800 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,11 +1,11 @@ import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; -import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; +import {CodexEventHandler, type CompletedPlan, failureWasShownAsMessage} from "./CodexEventHandler"; import {attachmentFileUri, desktopAttachmentHistory} from "./DesktopAttachmentHistory"; import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler"; import {PermissionLifecycleContext} from "./permissions/lifecycle"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; -import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; +import {type CodexAuthRequest, getCodexAuthMethods, getCodexAuthMethodsV2, isCodexAuthRequest} from "./CodexAuthMethod"; import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; import {createMcpServerSignIn, type McpServerSignIn} from "./mcp/McpServerSignIn"; import {getRequestedMcpServerNames, McpSessionStartup, parseMcpStartupAwaitTimeoutMs} from "./mcp/McpSessionStartup"; @@ -17,12 +17,17 @@ import { type SessionMetadataWithThread, type UrlElicitationRequester } from "./CodexAcpClient"; -import {CodexAppServerClient} from "./CodexAppServerClient"; +import { + type ApprovalHandler, + CodexAppServerClient, + type ElicitationHandler, +} from "./CodexAppServerClient"; import { isAccountReadAccountChangedError, isAccountReadAuthFailureError, isAccountReadUnavailableError, isNoActiveTurnError, + parseExpectedActiveTurnMismatch, } from "./CodexThreadErrors"; import {type CodexConnection} from "./CodexJsonRpcConnection"; import {AppServerRecovery, recoveryLimitsFromEnv} from "./app-server-recovery/AppServerRecovery"; @@ -35,7 +40,24 @@ import { import {CodexAppServerSupervisor} from "./app-server-recovery/CodexAppServerSupervisor"; import {lossAwareConnection} from "./app-server-recovery/LossAwareConnection"; import {AppServerConnectionLostError} from "./app-server-recovery/ConnectionLoss"; -import {type AcpClientConnection, ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; +import { + type AcpClientConnection, + ACPSessionConnection, + type AcpV2ClientConnection, + AcpV2Connection, + type ReplayMessageKind, + type UpdateSessionEvent, +} from "./ACPSessionConnection"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; +import {toV1ClientCapabilitiesView} from "./AcpV2ClientCapabilities"; +import {toV1SetSessionConfigOptionRequest, toV2ConfigOptions} from "./AcpV2ConfigOptions"; +import { + isInsertedUserMessage, + postInsertionFailureText, + toV1PromptRequest, + toV2IdleState, + type UserMessageInsertion, +} from "./AcpV2Prompt"; import type {InputModality, ReasoningEffort, ServerNotification} from "./app-server"; import type { Account, @@ -46,6 +68,8 @@ import type { Thread, ThreadGoal, ThreadItem, + ThreadItemEntry, + TurnStatus, UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; @@ -70,13 +94,14 @@ import { } from "./ModelConfigOption"; import type {TokenCount} from "./TokenCount"; import {PromptTokenUsage, toPromptUsage, ZERO_TOKEN_COUNT} from "./TokenCount"; -import {CodexCommands, GOAL_CONTINUATION_PROMPT} from "./CodexCommands"; +import {CodexCommands} from "./CodexCommands"; import {SteeringQueue} from "./SteeringQueue"; import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; import {settledWithin} from "./StdUtils"; import type {ToolCallReports} from "./ToolCallReports"; import {ToolCallReportingConnection} from "./ToolCallReportingConnection"; +import {type AcpMcpServer, getMcpServerName, type WithAcpMcpServers} from "./McpServerConfig"; import { AUTH_STATUS_META_KEY, AUTH_STATUS_UPDATE_METHOD, @@ -178,6 +203,7 @@ import type {SessionIndexTitleState} from "./SessionIndexTitles"; import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; import {CodexBackgroundTerminalTasks} from "./async-tasks/CodexBackgroundTerminalTasks"; import {clientSupportsCompaction, CodexSessionCompactions, createCompactionUpdate} from "./CodexSessionCompactions"; +import {CodexSessionToolCalls} from "./CodexSessionToolCalls"; import { type AgentFileChangeReport, type AgentFileChangeReportRequest, @@ -199,7 +225,22 @@ export interface SessionState extends SessionIndexTitleState { supportedInputModalities: Array, agentMode: AgentMode, collaborationMode: ModeKind, + /** The active turn: its completion, errors and stop reason are matched against this id. */ currentTurnId: string | null; + /** + * The id from the latest `turn/started`, which `turn/interrupt` and `turn/steer` need. It + * differs from `currentTurnId` only for a review: Codex reports the review's events and + * completion under the parent turn id, but treats the reviewer child turn as the running one. + */ + interruptTurnId: string | null; + /** + * The Codex-reported turn currently running on this thread (`turn/started` to + * `turn/completed`), tracked independently of whether a `session/prompt` started it. Backs + * the v2 "Codex turn running" busy signal and the `running`/`idle` states sent for a turn no + * v2 prompt owns (an auto goal continuation, or a turn Codex starts right after + * `session/resume`). + */ + codexReportedRunningTurnId: string | null; lastTokenUsage: TokenCount | null; totalTokenUsage: TokenCount | null; /** The token usage of the current or last prompt, which its PromptResponse reports. */ @@ -213,7 +254,7 @@ export interface SessionState extends SessionIndexTitleState { authProvider: string | null; cwd: string; additionalDirectories: string[]; - mcpServers?: Array; + mcpServers?: Array; fastModeEnabled: boolean; currentModelSupportsFast: boolean; sessionMcpServers?: Array; @@ -229,6 +270,12 @@ export interface SessionState extends SessionIndexTitleState { asyncTasks: CodexBackgroundTerminalTasks; compactions: CodexSessionCompactions; toolCallReports: ToolCallReports; + /** + * Tool-call items reported `item/started` but not yet `item/completed`, for item types + * with no outstanding-item tracker of their own. A provider restart's close-out fails + * whatever is still open here for a turn the dead app-server process never finished. + */ + openToolCalls: CodexSessionToolCalls; } export type SessionFailureCategory = @@ -276,6 +323,60 @@ const NO_ACTIVE_TURN_RETRY_DELAYS_MS = [25, 50, 100, 200, 400]; */ const ACCOUNT_READ_LOAD_GRACE_MS = 1_000; +/** A promise plus its own `resolve`, for settling a promise from outside its executor. */ +function createDeferred(): [Promise, (value: T) => void] { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return [promise, resolve]; +} + +/** + * Maps a turn id to the id Codex accepts for `turn/interrupt` and `turn/steer`. For the session's + * current turn that is the latest `turn/started` id (a review's child turn), once one arrived. + */ +function codexRunningTurnId(sessionState: SessionState, turnId: string): string { + return sessionState.currentTurnId === turnId + ? sessionState.interruptTurnId ?? turnId + : turnId; +} + +/** + * Simplified `turn/completed` status to v1 stop reason mapping for a turn no `session/prompt` + * owns, where there is no richer terminal-failure handling to consult: only an interruption + * counts as cancelled, a failure still ends the turn normally. + */ +function stopReasonForUnownedTurn(status: TurnStatus): acp.StopReason { + switch (status) { + case "completed": + case "failed": + return "end_turn"; + case "interrupted": + return "cancelled"; + case "inProgress": + // turn/completed never reports an in-progress turn. + return "end_turn"; + } +} + +/** + * Approval/elicitation handlers for the baseline Codex turn tracker installed before any prompt + * has run. They answer exactly as app-server already defaults to for a thread with no + * handler registered, since nothing here can meaningfully act on a request until a real prompt + * is issued. + */ +const DENY_ALL_APPROVALS: ApprovalHandler = { + handleCommandExecution: async () => ({decision: "cancel"}), + handleFileChange: async () => ({decision: "cancel"}), + handlePermissionsRequest: async () => ({permissions: {}, scope: "turn", strictAutoReview: false}), +}; + +const DENY_ALL_ELICITATIONS: ElicitationHandler = { + handleElicitation: async () => ({action: "cancel", content: null, _meta: null}), + handleUserInput: async () => ({answers: {}}), +}; + function clientSupportsTypedSessionFailures(capabilities: acp.ClientCapabilities | null): boolean { return clientSupportsAirCapability(capabilities, AIR_SESSION_FAILURE_KEY); } @@ -305,14 +406,42 @@ interface PendingTurnStart { resolve: (turnId: string | null) => void; } +/** + * One session's place in the shared per-session turn-start FIFO (see + * `CodexAcpServer.acquireTurnStartReservation`). `wait` resolves once every earlier reservation + * on the session has released; `release` must be called exactly once, by whoever ends up owning + * the turn this reservation was taken for, so the next queued starter can proceed. + * + * `needsWait` is false when there was nothing to wait for (a fresh session, or the previous + * holder already released). Callers should skip `await wait` in that case: awaiting an + * already-resolved promise still costs a microtask tick, which is enough to reorder synchronous + * setup (event-subscription registration, etc.) against a caller that fires a prompt without + * awaiting it and immediately does other synchronous work. + */ +interface TurnStartReservation { + wait: Promise; + needsWait: boolean; + release: () => void; +} + interface ActivePrompt { completion: Promise; closeSignal: Promise; cancelSignal: Promise; signal: AbortSignal; + /** + * Aborted for outbound permission/elicitation requests only (plain `session/cancel`, + * `requestCancel`, `requestClose`). Kept separate from `signal`, which also drives pre-turn + * prompt flow (`cancelBeforeTurnStarted`, local commands, native-subagent waits, + * `interruptLateStartedTurn`) and must keep its current behavior on plain `session/cancel`. + */ + interactionSignal: AbortSignal; + /** Set by plain `session/cancel` so the plan-review branch can detect cancellation without `signal` being aborted. */ + cancelRequested: boolean; currentTurn: { threadId: string, turnId: string } | null; requestCancel: () => void; requestClose: () => void; + abortInteractions: () => void; complete: () => void; /** The client sent `session/cancel` for this prompt. A prompt that ends after it answers `cancelled`. */ clientCancelled: boolean; @@ -337,6 +466,10 @@ export class CodexAcpServer { private readonly connection: AcpClientConnection; private readonly mcpServerSignIn: McpServerSignIn; private readonly reportingConnection: ToolCallReportingConnection; + /** ACP protocol version of the connection this agent serves, fixed by the protocol router. */ + readonly protocolVersion: 1 | 2; + /** The v2 client handle; `null` on a v1 connection. */ + private readonly v2Connection: AcpV2ClientConnection | null; private readonly defaultAuthRequest: CodexAuthRequest | null; private readonly getExitCode: () => number | null; private readonly getRecentStderr: () => string; @@ -364,16 +497,33 @@ export class CodexAcpServer { private readonly mcpSessionStartup: McpSessionStartup; private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; + /** Tail of the per-session turn-start FIFO; see `acquireTurnStartReservation`. */ + private readonly turnStartQueueTail: Map; settled: boolean}>; private readonly steeringQueues: Map; + /** + * Steers awaiting their `userMessage` landing, keyed by the minted `clientUserMessageId` + * passed to `turn/steer`. Used only to show a v2 live `user_message` when a steer injected + * into an already-running turn lands (there is no `prompt()` call to hook into for that + * path); v1 has nothing to emit. A steer that starts a new turn instead goes through + * `prompt()`'s own `UserMessageInsertion` tracking and never enters this map. + */ + private readonly pendingSteerLandings: Map; + /** Sessions with a v2 prompt that has not finished yet, including before its turn starts. */ + private readonly v2PromptsInFlight = new Set(); + /** + * Per-session callbacks that abort a v2 `session/prompt` still waiting in the turn-start FIFO + * (queued behind a running turn, not yet inserted). `session/cancel`/`session/close` drop the + * whole queue for a session by invoking every registered callback here. + */ + private readonly queuedV2PromptCancellers = new Map void>>(); private readonly closingSessions: Map; private readonly sessionGenerations: Map; private readonly sessionOpenGenerations: Map; - private readonly goalControlGenerations: Map; private readonly permissionLifecycleContexts: WeakMap; private readonly codexProcessState: CodexProcessState | null; /** Brings the app-server back after a crash; `null` without a process state (tests). */ private readonly recovery: AppServerRecovery | null; - private initializeRequest: acp.InitializeRequest | null = null; + private initializeRequest: Pick | null = null; private providerUpdate: Promise | null = null; /** The AIR `sessionIndex` extension, see `SessionIndexService.ts`. */ readonly sessionIndex = new SessionIndexService({ @@ -393,7 +543,7 @@ export class CodexAcpServer { }); constructor( - connection: AcpClientConnection, + connection: AcpClientConnection | AcpV2Connection, codexAcpClient: CodexAcpClient, defaultAuthRequest?: CodexAuthRequest, getExitCode?: () => number | null, @@ -403,14 +553,31 @@ export class CodexAcpServer { this.sessions = new Map(); this.pendingTurnStarts = new Map(); this.activePrompts = new Map(); + this.turnStartQueueTail = new Map(); this.steeringQueues = new Map(); + this.pendingSteerLandings = new Map(); this.closingSessions = new Map(); this.sessionGenerations = new Map(); this.sessionOpenGenerations = new Map(); - this.goalControlGenerations = new Map(); this.permissionLifecycleContexts = new WeakMap(); - this.reportingConnection = new ToolCallReportingConnection(connection); - this.connection = this.reportingConnection.asClientConnection(); + if (connection instanceof AcpV2Connection) { + this.protocolVersion = 2; + this.v2Connection = connection.client; + this.reportingConnection = new ToolCallReportingConnection(connection.extensionOnlyV1View()); + this.connection = this.reportingConnection.asClientConnection(); + connection.registerView(this.connection); + // A permission request that outlives its turn must not undo the `idle` already sent + // for it: only send `running` back if the session is actually still busy -- + // either a Codex turn is running, or a v2 prompt is in flight between two Codex + // turns of the same prompt (e.g. the plan/implementation approval gap), where no + // `codexReportedRunningTurnId` is set yet but the client is still mid-`requires_action`. + connection.setTurnRunningCheck((sessionId) => this.isSessionBusy(sessionId)); + } else { + this.protocolVersion = 1; + this.v2Connection = null; + this.reportingConnection = new ToolCallReportingConnection(connection); + this.connection = this.reportingConnection.asClientConnection(); + } this.codexAcpClient = codexAcpClient; this.mcpServerSignIn = createMcpServerSignIn( this.connection, @@ -460,17 +627,12 @@ export class CodexAcpServer { this.capabilities = ClientCapabilities.from(_params.clientCapabilities); const appServer = this.ensureAppServer(); if (appServer) await appServer; - this.reportingConnection.reports.compareMeta = this.capabilities.airClient; + this.reportingConnection.reports.compareMeta = this.capabilities.airToolCallContract; this.booleanConfigOptionsSupported = clientSupportsBooleanConfigOptions(_params.clientCapabilities); this.sessionIndex.configure(_params.clientCapabilities); await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params)); this.sessionIndex.observe(this.codexAcpClient); this.publishFirstAuthStatusAfterResponse(); - const goalCapability = { - version: GOAL_EXTENSION_VERSION, - controlMethod: GOAL_CONTROL_METHOD, - actions: [...GOAL_CONTROL_ACTIONS], - }; const sessionCapabilities: SubagentAwareSessionCapabilities = { resume: { }, list: { }, @@ -510,33 +672,106 @@ export class CodexAcpServer { }, }, authMethods: getCodexAuthMethods(_params.clientCapabilities), - _meta: { - steering: { - supported: true, + _meta: this.initializeExtensionsMeta(1), + }; + } + + async initializeV2( + params: acpV2.InitializeRequest, + ): Promise { + logger.log("Initialize request received", {protocolVersion: params.protocolVersion}); + // Existing capability readers take the v1 shape; v2 fields they read keep their relative path. + const clientCapabilities = toV1ClientCapabilitiesView(params.capabilities); + this.clientInfo = params.info; + this.clientCapabilities = clientCapabilities; + this.initializeRequest = {clientInfo: params.info}; + this.capabilities = ClientCapabilities.fromV2(clientCapabilities); + const appServer = this.ensureAppServer(); + if (appServer) await appServer; + this.reportingConnection.reports.compareMeta = this.capabilities.airToolCallContract; + // Boolean config options are baseline on v2, so there is nothing to probe. + this.booleanConfigOptionsSupported = true; + await this.runWithProcessCheck(() => this.codexAcpClient.initialize({clientInfo: params.info})); + this.publishFirstAuthStatusAfterResponse(); + return { + protocolVersion: 2, + info: { + name: packageJson.name, + title: "Codex", + version: packageJson.version, + }, + capabilities: { + auth: { + _meta: { + // Presence means "this agent pushes `_auth/status_update`". + [AUTH_STATUS_META_KEY]: authStatusCapability(), + }, }, - // Only AIR gets the AIR extension, see `docs/air-extensions.md`. - ...(this.capabilities.airClient ? { - [JETBRAINS_META_KEY]: { - [AIR_META_KEY]: { - [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, - [AIR_GOAL_KEY]: goalCapability, - [AIR_EXTENSION_CAPABILITIES_KEY]: [ - AIR_SESSION_FAILURE_KEY, - AIR_DIFF_PATCH_KEY, - AIR_AGENT_FILE_CHANGE_REPORT_KEY, - AIR_CUSTOM_INSTRUCTIONS_KEY, - AIR_NATIVE_SUBAGENT_SESSIONS_KEY, - AIR_ASYNC_TASKS_KEY, - AIR_RECOMMENDED_CONFIG_VALUE_KEY, - AIR_RAW_INPUT_RENDERING_KEY, - AIR_PLAN_CONTENT_DELTA_KEY, - AIR_CODEX_HOOKS_KEY, - ...this.sessionIndex.agentCapabilities(), - ], - }, + providers: {}, + session: { + prompt: { + embeddedContext: {}, + image: {}, }, - } : {}), + mcp: { + stdio: {}, + http: {}, + }, + delete: {}, + additionalDirectories: {}, + fork: {}, + }, }, + authMethods: getCodexAuthMethodsV2(clientCapabilities), + _meta: this.initializeExtensionsMeta(2), + }; + } + + /** + * The extension metadata of the initialize response. Only AIR gets the AIR extension, see + * `docs/air-extensions.md`. v2 has its own terminal, diff and plan updates, so the AIR + * tool call and plan capabilities are not offered there. + */ + private initializeExtensionsMeta(protocolVersion: 1 | 2): Record { + const airCapabilities = protocolVersion === 1 + ? [ + AIR_SESSION_FAILURE_KEY, + AIR_DIFF_PATCH_KEY, + AIR_AGENT_FILE_CHANGE_REPORT_KEY, + AIR_CUSTOM_INSTRUCTIONS_KEY, + AIR_NATIVE_SUBAGENT_SESSIONS_KEY, + AIR_ASYNC_TASKS_KEY, + AIR_RECOMMENDED_CONFIG_VALUE_KEY, + AIR_RAW_INPUT_RENDERING_KEY, + AIR_PLAN_CONTENT_DELTA_KEY, + AIR_CODEX_HOOKS_KEY, + ...this.sessionIndex.agentCapabilities(), + ] + : [ + AIR_SESSION_FAILURE_KEY, + AIR_AGENT_FILE_CHANGE_REPORT_KEY, + AIR_CUSTOM_INSTRUCTIONS_KEY, + AIR_NATIVE_SUBAGENT_SESSIONS_KEY, + AIR_ASYNC_TASKS_KEY, + AIR_RECOMMENDED_CONFIG_VALUE_KEY, + ]; + return { + steering: { + supported: true, + }, + ...(this.capabilities.airClient ? { + [JETBRAINS_META_KEY]: { + [AIR_META_KEY]: { + [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, + [AIR_GOAL_KEY]: { + version: GOAL_EXTENSION_VERSION, + controlMethod: GOAL_CONTROL_METHOD, + actions: [...GOAL_CONTROL_ACTIONS], + }, + [AIR_EXTENSION_CAPABILITIES_KEY]: airCapabilities, + }, + }, + } : {}), }; } @@ -596,26 +831,9 @@ export class CodexAcpServer { const sessionReady = this.ensureSessionReady(sessionState); if (sessionReady) await sessionReady; const sessionGeneration = this.getSessionGeneration(sessionState.sessionId); - const goalControlGeneration = this.bumpGoalControlGeneration(sessionState.sessionId); if (methodRequest.params.action === "set") { const objective = methodRequest.params.objective; - let updatedGoal: ThreadGoal | null = null; - const turnCompleted = await this.runWithProcessCheck(() => this.codexAcpClient.setGoal( - sessionState.sessionId, - objective, - undefined, - (goal) => { - updatedGoal = goal; - }, - )); - if (turnCompleted === null && updatedGoal !== null) { - await this.startGoalContinuationIfCurrent( - sessionState, - sessionGeneration, - goalControlGeneration, - updatedGoal, - ); - } + await this.runWithProcessCheck(() => this.codexAcpClient.setGoal(sessionState.sessionId, objective)); } else if (methodRequest.params.action === "pause") { const goal = await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, "paused")); if (this.sessionPublishIsCurrent(sessionState, sessionGeneration)) { @@ -623,7 +841,7 @@ export class CodexAcpServer { } } else if (methodRequest.params.action === "resume") { let updatedGoal: ThreadGoal | null = null; - const turnCompleted = await this.runWithProcessCheck(() => this.codexAcpClient.resumeGoal( + await this.runWithProcessCheck(() => this.codexAcpClient.resumeGoal( sessionState.sessionId, undefined, (goal) => { @@ -633,14 +851,6 @@ export class CodexAcpServer { if (updatedGoal !== null && this.sessionPublishIsCurrent(sessionState, sessionGeneration)) { await this.publishGoalSnapshot(sessionState, toThreadGoalSnapshot(updatedGoal), false); } - if (turnCompleted === null && updatedGoal !== null) { - await this.startGoalContinuationIfCurrent( - sessionState, - sessionGeneration, - goalControlGeneration, - updatedGoal, - ); - } } else if (methodRequest.params.action === "clear") { await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionState.sessionId)); if (this.sessionPublishIsCurrent(sessionState, sessionGeneration)) { @@ -706,7 +916,9 @@ export class CodexAcpServer { return requirement.account; } - async getOrCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { + async getOrCreateSession( + request: WithAcpMcpServers | WithAcpMcpServers, + ): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { try { return await this.tryCreateSession(request); } catch (e) { @@ -816,12 +1028,6 @@ export class CodexAcpServer { return this.sessionGenerations.get(sessionId) ?? 0; } - private bumpGoalControlGeneration(sessionId: string): number { - const generation = (this.goalControlGenerations.get(sessionId) ?? 0) + 1; - this.goalControlGenerations.set(sessionId, generation); - return generation; - } - private bumpSessionGeneration(sessionId: string): number { const generation = this.getSessionGeneration(sessionId) + 1; this.sessionGenerations.set(sessionId, generation); @@ -829,10 +1035,12 @@ export class CodexAcpServer { } async tryCreateSession( - request: acp.NewSessionRequest | acp.ResumeSessionRequest | acp.ForkSessionRequest, + request: WithAcpMcpServers + | WithAcpMcpServers + | WithAcpMcpServers, operation: "new" | "resume" | "fork" = "sessionId" in request ? "resume" : "new", ): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { - const existingSessionRequest = request as acp.ResumeSessionRequest | acp.ForkSessionRequest; + const existingSessionRequest = request as WithAcpMcpServers | WithAcpMcpServers; const requestedSessionGeneration = operation === "resume" ? this.beginSessionOpen(existingSessionRequest.sessionId) : null; @@ -848,9 +1056,15 @@ export class CodexAcpServer { // The app-server that loads the thread, read in the closure that loads it. A crash after the load makes the // session stale, see AppServerRecovery. let openGeneration = this.recovery?.generation ?? 0; + // Registered before `thread/resume` is even sent, so a goal turn Codex auto-starts on the + // resumed thread can't slip in before a handler exists; see `startCodexTurnTracker`. + let settleTrackerReady: ((state: SessionState | null) => void) | null = null; if (operation === "resume") { - const resumeRequest = request as acp.ResumeSessionRequest; + const resumeRequest = request as WithAcpMcpServers; logger.log(`Resume existing session: ${resumeRequest.sessionId}...`); + const [trackerReady, resolveTrackerReady] = createDeferred(); + settleTrackerReady = resolveTrackerReady; + this.startCodexTurnTracker(resumeRequest.sessionId, trackerReady); try { const pendingResume = this.recovery?.settleResume(resumeRequest.sessionId); if (pendingResume) await pendingResume; @@ -862,13 +1076,18 @@ export class CodexAcpServer { }); }); } catch (err) { + settleTrackerReady?.(null); if (resumeSubscribed && requestedSessionGeneration !== null) { await this.cleanupStaleSessionOpen(resumeRequest.sessionId, requestedSessionGeneration); + } else { + // `thread/resume` never subscribed the connection, so there is nothing for + // `cleanupStaleSessionOpen` to unsubscribe; just drop the local handler. + this.codexAcpClient.discardSessionSubscription(resumeRequest.sessionId); } throw err; } } else if (operation === "fork") { - const forkRequest = request as acp.ForkSessionRequest; + const forkRequest = request as WithAcpMcpServers; logger.log(`Fork existing session: ${forkRequest.sessionId}...`); sessionMetadata = await this.runWithProcessCheck(() => { openGeneration = this.recovery?.generation ?? 0; @@ -880,7 +1099,7 @@ export class CodexAcpServer { sessionMetadata = await this.runWithProcessCheck(() => { openGeneration = this.recovery?.generation ?? 0; mcpServerStartupVersion = readMcpServerStartupVersion(); - return this.codexAcpClient.newSession(request as acp.NewSessionRequest); + return this.codexAcpClient.newSession(request as WithAcpMcpServers); }); } @@ -890,6 +1109,7 @@ export class CodexAcpServer { try { authState = await this.getAuthStateForProvider(authProvider, knownAccount); } catch (err) { + settleTrackerReady?.(null); if (resumeSubscribed && requestedSessionGeneration !== null) { await this.cleanupStaleSessionOpen(sessionId, requestedSessionGeneration); } else if (operation === "fork") { @@ -900,6 +1120,7 @@ export class CodexAcpServer { } const sessionGeneration = requestedSessionGeneration ?? this.beginSessionOpen(sessionId); if (!this.sessionOpenCanInstall(sessionId, sessionGeneration)) { + settleTrackerReady?.(null); resumeSubscribed = false; await this.closeStaleSessionOpen(sessionId, sessionGeneration); } @@ -915,6 +1136,8 @@ export class CodexAcpServer { agentMode: AgentMode.getInitialAgentMode(), collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, + interruptTurnId: null, + codexReportedRunningTurnId: null, lastTokenUsage: null, totalTokenUsage: null, promptTokenUsage: null, @@ -943,6 +1166,7 @@ export class CodexAcpServer { asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), toolCallReports: this.reportingConnection.reports, + openToolCalls: new CodexSessionToolCalls(), }; sessionState.titleGen = new TitleGenerator( this.codexAcpClient.appServerClient, @@ -953,6 +1177,11 @@ export class CodexAcpServer { ); this.installSessionState(sessionState); this.recovery?.markLoaded(sessionState, openGeneration); + if (settleTrackerReady) { + settleTrackerReady(sessionState); + } else { + this.startCodexTurnTracker(sessionId, Promise.resolve(sessionState)); + } resumeSubscribed = false; const canPublishSessionUpdates = operation !== "fork"; @@ -1134,6 +1363,190 @@ export class CodexAcpServer { this.sessions.set(sessionState.sessionId, sessionState); } + /** + * Installs a baseline session-scoped subscription, so a Codex-initiated turn starting before + * any `session/prompt` has run still updates `codexReportedRunningTurnId`, gets its + * `running`/`idle` states, and renders its items. `prompt()`'s own + * `subscribeToSessionEvents` call permanently replaces this dispatch + * (`CodexSubagentSubscriptions.subscribe` keeps a single `current` subscription per session) + * the first time a prompt runs, and that subscription's own leftover-rendering path takes over + * from then on; this baseline handler only ever dispatches for a session no prompt has + * subscribed to yet, so it cannot double-render. It answers approval/elicitation requests + * exactly like app-server's own default for a thread with no handler registered, so no real + * prompt has yet run to answer. + * + * `ready` lets a resume/load caller register this *before* `thread/resume`/`thread/load` is + * even sent, so a goal turn Codex auto-starts on the resumed thread within a few ms of the + * response can't slip in before a handler exists (nothing else buffers dropped notifications). + * The per-session notification queue (`enqueueSessionNotification`) serializes handler calls, + * so awaiting `ready` in the first event holds every later event for this session in order; + * nothing is dropped or reordered. `ready` resolves to the real `SessionState` once install + * finishes (or `null` on a failed/superseded open, in which case events are silently ignored). + */ + private startCodexTurnTracker(sessionId: string, ready: Promise): void { + let baselineEventHandler: CodexEventHandler | null = null; + void this.codexAcpClient.subscribeToSessionEvents( + sessionId, + async (event) => { + const sessionState = await ready; + if (!sessionState) return; + if (!baselineEventHandler) { + baselineEventHandler = new CodexEventHandler( + this.connection, + sessionState, + clientSupportsTypedSessionFailures(this.clientCapabilities), + this.sessionFailureEpoch, + sessionState.subagents, + (accountUpdated) => this.handleAccountUpdated(accountUpdated), + false, + clientSupportsCompaction(this.clientCapabilities), + clientSupportsNotices(this.clientCapabilities), + ); + } + await this.trackCodexTurnStart(sessionState, event); + await this.trackSteerLanding(sessionState, event); + // Codex-started turns carry no `userMessage` item; `handleSessionScopedNotification` + // already drops that item type, so nothing is synthesized here. + await baselineEventHandler.handleSessionScopedNotification(event); + await this.trackCodexTurnCompletion(sessionState, event); + }, + DENY_ALL_APPROVALS, + DENY_ALL_ELICITATIONS, + clientSupportsSubagents(this.clientCapabilities), + () => {}, + async () => null, + ); + } + + /** + * Whether Codex reports a turn currently running on the thread, from `turn/started`/ + * `turn/completed` -- independent of whether a `session/prompt` started it. + */ + private isCodexTurnRunning(sessionId: string): boolean { + return this.sessions.get(sessionId)?.codexReportedRunningTurnId != null; + } + + /** + * Whether the session is busy enough that a settled permission request should resume + * `running` rather than leave the client at `requires_action`: either a Codex turn is + * running, or a v2 prompt is in flight (covers the gap between two Codex turns of the same + * prompt, e.g. the plan/implementation approval, where no turn has started yet). + */ + private isSessionBusy(sessionId: string): boolean { + return this.isCodexTurnRunning(sessionId) || this.v2PromptsInFlight.has(sessionId); + } + + /** + * Tracks a Codex-reported turn starting, independent of whether a `session/prompt` started + * it, and sends `running` for a turn no v2 prompt owns. + */ + private async trackCodexTurnStart(sessionState: SessionState, event: ServerNotification): Promise { + if (event.method !== "turn/started" || event.params.threadId !== sessionState.sessionId) { + return; + } + sessionState.codexReportedRunningTurnId = event.params.turn.id; + await this.reportUnownedTurnState(sessionState.sessionId, {state: "running"}); + } + + /** + * The other half of `trackCodexTurnStart`: sends exactly one `idle` for a turn no v2 prompt + * owns, after the notification's own content has already been handled so `idle` + * stays the last thing sent for the turn. + */ + private async trackCodexTurnCompletion(sessionState: SessionState, event: ServerNotification): Promise { + if (event.method !== "turn/completed" || event.params.threadId !== sessionState.sessionId) { + return; + } + if (sessionState.codexReportedRunningTurnId === event.params.turn.id) { + sessionState.codexReportedRunningTurnId = null; + } + await this.reportUnownedTurnState(sessionState.sessionId, { + state: "idle", + stopReason: stopReasonForUnownedTurn(event.params.turn.status), + }); + } + + /** + * Sends the v2 `state_update` for a Codex-reported turn no `session/prompt` owns. A turn a + * v2 prompt owns sends its own states already, so this is a no-op while one is in flight for + * the session; it is also a no-op on v1, which has no `state_update`. + */ + private async reportUnownedTurnState(sessionId: string, state: acpV2.StateUpdate): Promise { + if (this.v2PromptsInFlight.has(sessionId)) { + return; + } + const session = new ACPSessionConnection(this.connection, sessionId); + if (session.protocolVersion !== 2) { + return; + } + try { + await session.updateState(state); + } catch (error) { + logger.error(`Failed to send the '${state.state}' state for session ${sessionId}`, error); + } + } + + /** + * Fails every tool call this session's tracker still has open (a provider restart's + * dead app-server process never sent `item/completed` for it). No-op if nothing is open, e.g. + * every item on the cut-off turn already completed before the restart. Runs on v1 and v2 + * alike -- `ACPSessionConnection.update()` renders each accordingly. + */ + private async finishOutstandingToolCalls(session: SessionState): Promise { + const updates = session.openToolCalls.finishOutstanding(); + if (updates.length === 0) { + return; + } + const connection = new ACPSessionConnection(this.connection, session.sessionId); + for (const update of updates) { + await connection.update(update); + } + } + + /** + * Matches an injected steer's `userMessage` landing against `pendingSteerLandings`, and + * drops any entries a completed turn never delivered (Codex dropped the steered input + * silently, so nothing is shown for it). Called from every session-scoped subscription + * (the baseline one and each prompt's own), so it works whether the steer lands inside a + * v2-prompt-owned turn or an unowned one. + */ + private async trackSteerLanding(sessionState: SessionState, event: ServerNotification): Promise { + if (event.method === "turn/completed" && event.params.threadId === sessionState.sessionId) { + for (const [clientUserMessageId, entry] of this.pendingSteerLandings) { + if (entry.sessionId === sessionState.sessionId) { + this.pendingSteerLandings.delete(clientUserMessageId); + } + } + return; + } + for (const [clientUserMessageId, entry] of this.pendingSteerLandings) { + if (entry.sessionId === sessionState.sessionId + && isInsertedUserMessage(event, sessionState.sessionId, clientUserMessageId)) { + this.pendingSteerLandings.delete(clientUserMessageId); + await this.emitLiveSteerUserMessage(sessionState.sessionId, clientUserMessageId, entry.prompt); + return; + } + } + } + + /** + * Shows a landed steer as a live-only `user_message` (v2 only; the steering response itself + * carries no `messageId`, per user decision). + */ + private async emitLiveSteerUserMessage(sessionId: string, messageId: string, prompt: acp.ContentBlock[]): Promise { + const session = new ACPSessionConnection(this.connection, sessionId); + if (session.protocolVersion !== 2) { + return; + } + try { + for (const block of prompt) { + await session.update(createUserMessageChunk(block, messageId)); + } + } catch (error) { + logger.error(`Failed to send the steered user message for session ${sessionId}`, error); + } + } + private getAuthProviderForAuthenticateRequest(request: acp.AuthenticateRequest): string | null { if (isCodexAuthRequest(request) && request.methodId === "gateway") { return "custom-gateway"; @@ -1154,6 +1567,31 @@ export class CodexAcpServer { const appServer = this.ensureAppServer(); if (appServer) await appServer; logger.log("Loading session...", {sessionId: params.sessionId}); + const {sessionId, modelState, modeState} = await this.loadSessionAndReplayHistory(params); + + logger.log("Session loaded", { + sessionId: sessionId, + modelId: modelState.currentModelId, + availableModelCount: modelState.availableModels.length + }); + return { + models: modelState, + modes: modeState, + ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), + }; + } + + /** + * Shared by v1 `session/load` and v2 `session/resume` with `replayFrom: {type: "start"}`: + * reattach, replay retained history as ordinary `session/update`s, then answer. + */ + private async loadSessionAndReplayHistory( + params: WithAcpMcpServers, + ): Promise<{ + sessionId: SessionId; + modelState: LegacySessionModelState; + modeState: SessionModeState; + }> { // Captured before the load installs a fresh SessionState: a title // generation started by an earlier turn on this session belongs to the // state being replaced, and has to settle before we answer. @@ -1164,10 +1602,18 @@ export class CodexAcpServer { modeState, thread, history, + sessionState, + settleTrackerReady, } = await this.getOrCreateSessionWithHistory(params); try { - await this.streamThreadHistory(sessionId, thread, history); + try { + await this.streamThreadHistory(sessionId, thread, history); + } finally { + // Only after replay is fully streamed does the baseline tracker start dispatching + // live events, so a live goal-turn frame can never race ahead of history. + settleTrackerReady(sessionState); + } } catch (err) { // A close during the load already closed the session. if (err instanceof SessionClosedDuringLoadError) { @@ -1185,25 +1631,16 @@ export class CodexAcpServer { // from a still-running title generation would arrive after it. await previousTitleGen?.waitForIdle(TITLE_GENERATION_SETTLE_TIMEOUT_MS); - logger.log("Session loaded", { - sessionId: sessionId, - modelId: modelState.currentModelId, - availableModelCount: modelState.availableModels.length - }); - return { - models: modelState, - modes: modeState, - ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), - }; + return {sessionId, modelState, modeState}; } - async resumeSession(params: acp.ResumeSessionRequest): Promise { + async resumeSession(params: WithAcpMcpServers): Promise { return this.recovery === null ? await this.resumeSessionOnce(params) : await this.recovery.trackOpen(params.sessionId, () => this.resumeSessionOnce(params)); } - private async resumeSessionOnce(params: acp.ResumeSessionRequest): Promise { + private async resumeSessionOnce(params: WithAcpMcpServers): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } @@ -1224,7 +1661,20 @@ export class CodexAcpServer { }; } - async forkSession(params: acp.ForkSessionRequest): Promise { + async resumeSessionV2(params: acpV2.ResumeSessionRequest): Promise { + const {replayFrom, ...request} = params; + if (replayFrom == null) { + await this.resumeSession(request); + return this.createSessionConfigOptionsResponseV2(this.getSessionState(params.sessionId)); + } + if (replayFrom.type !== "start") { + throw RequestError.invalidParams(undefined, `Unsupported replayFrom type: ${replayFrom.type}`); + } + const {sessionId} = await this.loadSessionAndReplayHistory(request); + return this.createSessionConfigOptionsResponseV2(this.getSessionState(sessionId)); + } + + async forkSession(params: WithAcpMcpServers): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } @@ -1246,6 +1696,14 @@ export class CodexAcpServer { } } + async forkSessionV2(params: acpV2.ForkSessionRequest): Promise { + const {sessionId} = await this.forkSession(params); + return { + sessionId, + ...this.createSessionConfigOptionsResponseV2(this.getSessionState(sessionId)), + }; + } + async listSessions(params: acp.ListSessionsRequest): Promise { const appServer = this.ensureAppServer(); if (appServer) await appServer; @@ -1277,6 +1735,8 @@ export class CodexAcpServer { this.beginSessionCloseFence(params.sessionId); try { + // Same as `session/cancel`: drop every v2 prompt still queued for this session first. + this.cancelQueuedV2Prompts(params.sessionId); if (sessionState) { await this.interruptSessionTurn(sessionState, "Close", true); sessionState.asyncTasks.clear(); @@ -1304,8 +1764,9 @@ export class CodexAcpServer { this.codexAcpClient.appServerClient.mcpStartup.forgetThread(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); this.activePrompts.delete(params.sessionId); + this.turnStartQueueTail.delete(params.sessionId); + this.queuedV2PromptCancellers.delete(params.sessionId); this.steeringQueues.delete(params.sessionId); - this.goalControlGenerations.delete(params.sessionId); } this.endSessionCloseFence(params.sessionId); } @@ -1355,7 +1816,7 @@ export class CodexAcpServer { } async newSession( - params: acp.NewSessionRequest, + params: WithAcpMcpServers, ): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); @@ -1379,6 +1840,14 @@ export class CodexAcpServer { }; } + async newSessionV2(params: acpV2.NewSessionRequest): Promise { + const {sessionId} = await this.newSession(params); + return { + sessionId, + ...this.createSessionConfigOptionsResponseV2(this.getSessionState(sessionId)), + }; + } + async authenticate( _params: acp.AuthenticateRequest, requestId?: acp.JsonRpcId, @@ -1431,6 +1900,20 @@ export class CodexAcpServer { logger.log("Logout request completed"); } + /** v2 `auth/login`: same params as v1 `authenticate`, only the method name changed. */ + async authenticateV2( + params: acpV2.LoginAuthRequest, + requestId?: acpV2.JsonRpcId, + ): Promise { + return await this.authenticate(params, requestId); + } + + /** v2 `auth/logout`: same params as v1 `logout`, only the method name changed. */ + async logoutV2(params: acpV2.LogoutAuthRequest): Promise { + await this.logout(params); + return {}; + } + listProviders(_params: acp.ListProvidersRequest): acp.ListProvidersResponse { return { providers: this.codexAcpClient.listProviders() }; } @@ -1469,6 +1952,11 @@ export class CodexAcpServer { } await this.finishAllAsyncTasks("stopped", "before the provider restart"); const replacement = await this.restartCodexClient(); + // Captured before the swap: draining its per-session queues below (after the process + // it wraps has already exited) is how a turn left running on the old client gets + // closed out, since the old process's EOF drops the notification and no + // `turn/completed` ever arrives for it. + const previousClient = this.codexAcpClient; apply(replacement); if (this.initializeRequest === null) { throw new Error("Cannot restart Codex app-server before ACP initialization"); @@ -1482,12 +1970,39 @@ export class CodexAcpServer { this.recovery?.completeReplacement(replacement); const replacementGeneration = this.recovery?.generation ?? 0; for (const session of this.sessions.values()) { + // v2 only: a turn still running when the old process was killed never gets its + // `turn/completed` -- the old process's EOF just drops the notification -- which + // would otherwise leave the session wedged at `running` forever (an R13 MUST + // violation) and `isSessionBusy` stuck true. Drain the old client's queue first so + // an already-buffered `turn/completed` still clears this normally; only a turn + // genuinely orphaned by the restart gets force-closed. No-op on v1 (no state + // channel) and while a v2 prompt is in flight for the session (its own `idle` + // closes the state). This must happen before this session's tracker is registered + // and it's resumed: Codex can auto-start a continuation turn within a few ms of + // `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.codexReportedRunningTurnId !== null) { + session.codexReportedRunningTurnId = null; + // the dead process's EOF drops `item/completed` for anything still open on + // the cut-off turn (v1 + v2), leaving the client with a spinner forever. Fail + // those tool calls -- and end their terminals -- before the turn's own + // idle/cancelled close-out below. + await this.finishOutstandingToolCalls(session); + await this.reportUnownedTurnState(session.sessionId, {state: "idle", stopReason: "cancelled"}); + } + session.asyncTasks.setAppServer(replacement.appServerClient); session.titleGen?.rebindAppServer(replacement.appServerClient); if (replacement.appServerClient.connectionLoss.lost) { // The new app-server died: the next use of each session resumes it in the restarted one. break; } + // Registered before `resumeSession`, so a goal turn Codex auto-starts within a + // few ms of `thread/resume`'s response can't slip past an empty subscription + // registry on the new client (10(f1) `startCodexTurnTracker`). + const [trackerReady, settleTrackerReady] = createDeferred(); + this.startCodexTurnTracker(session.sessionId, trackerReady); try { const request = { sessionId: session.sessionId, @@ -1501,8 +2016,10 @@ export class CodexAcpServer { await (this.recovery?.resumeForReplacement(session, replacementGeneration, replacement, resume) ?? resume()); session.authProvider = replacement.getModelProvider(); session.asyncTasks.refresh(); + settleTrackerReady(session); logger.log("Resumed session after provider restart", {sessionId: session.sessionId}); } catch (error) { + settleTrackerReady(null); if (error instanceof ThreadRefusedError || error instanceof SessionReplacedError || error instanceof UnpersistedSessionLostError) { // A refused session stays not live and its next use reports the refusal; a session that closed @@ -1870,6 +2387,13 @@ export class CodexAcpServer { }; } + async setSessionConfigOptionV2( + params: acpV2.SetSessionConfigOptionRequest, + ): Promise { + const response = await this.setSessionConfigOption(toV1SetSessionConfigOptionRequest(params)); + return {configOptions: toV2ConfigOptions(response.configOptions)}; + } + private async applySessionConfigOption(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): Promise { switch (params.configId) { case FAST_MODE_CONFIG_ID: @@ -2075,15 +2599,20 @@ export class CodexAcpServer { const sessionState = this.getSessionState(params.sessionId); this.assertSteerInputSupported(params, sessionState); + // Minted fresh for every steer (both protocol versions), passed to Codex as + // `TurnSteerParams.clientUserMessageId`/`TurnStartParams.clientUserMessageId`. It is a + // Codex-side param only; on v2 it doubles as the `messageId` of the live `user_message` + // shown once the steer lands (the steering response itself carries no id). + const clientUserMessageId = randomUUID(); const turnId = await this.getSteerableTurnId(sessionState); if (turnId) { - const injected = await this.injectSteerIntoActiveTurn(params, turnId, sessionState); + const injected = await this.injectSteerIntoActiveTurn(params, turnId, sessionState, clientUserMessageId); if (injected) { logger.log("Steering session injected", {sessionId: params.sessionId, turnId}); return {outcome: "injected"}; } } - return await this.startNewTurnFromSteering(params); + return await this.startNewTurnFromSteering(params, clientUserMessageId); } /** @@ -2112,17 +2641,24 @@ export class CodexAcpServer { params: SessionSteerRequest, turnId: string, sessionState: SessionState, + clientUserMessageId: string, ): Promise { + // Registered before the call goes out (not after `steerTurn` resolves), so the landing + // matcher catches a userMessage that arrives immediately after acceptance. + this.pendingSteerLandings.set(clientUserMessageId, {sessionId: params.sessionId, prompt: params.prompt}); try { await this.runWithProcessCheck(() => this.codexAcpClient.steerTurn({ threadId: params.sessionId, turnId, prompt: params.prompt, + clientUserMessageId, })); return true; } catch (err) { + this.pendingSteerLandings.delete(clientUserMessageId); await this.codexAcpClient.waitForSessionNotifications(params.sessionId); - const turnStillActive = sessionState.currentTurnId === turnId; + const turnStillActive = sessionState.currentTurnId !== null + && codexRunningTurnId(sessionState, sessionState.currentTurnId) === turnId; if (turnStillActive && !this.isNoActiveTurnToSteerError(err)) { throw err; } @@ -2142,47 +2678,41 @@ export class CodexAcpServer { * @returns "startedNewTurn" once the turn is running; throws if the prompt * fails or is cancelled before the turn starts. */ - private async startNewTurnFromSteering(params: SessionSteerRequest): Promise { - await this.startNewTurnFromExternalPrompt(params, "Steering"); - return {outcome: "startedNewTurn"}; - } - - private async startGoalContinuationIfCurrent( - sessionState: SessionState, - sessionGeneration: number, - goalControlGeneration: number, - expectedGoal: ThreadGoal, - ): Promise { - await this.startNewTurnFromExternalPrompt({ - sessionId: sessionState.sessionId, - prompt: GOAL_CONTINUATION_PROMPT, - }, "Goal continuation", async () => { - if (!this.sessionPublishIsCurrent(sessionState, sessionGeneration) - || this.goalControlGenerations.get(sessionState.sessionId) !== goalControlGeneration) { - return false; - } - const currentGoal = await this.runWithProcessCheck(() => this.codexAcpClient.getGoal(sessionState.sessionId)); - return currentGoal?.status === "active" - && currentGoal.objective === expectedGoal.objective - && currentGoal.createdAt === expectedGoal.createdAt - && this.goalControlGenerations.get(sessionState.sessionId) === goalControlGeneration; + private async startNewTurnFromSteering( + params: SessionSteerRequest, + clientUserMessageId: string, + ): Promise { + await this.startNewTurnFromExternalPrompt(params, "Steering", undefined, { + clientUserMessageId, + onInserted: async () => { + await this.emitLiveSteerUserMessage(params.sessionId, clientUserMessageId, params.prompt); + }, + onSyntheticInserted: async () => {}, }); + return {outcome: "startedNewTurn"}; } private async startNewTurnFromExternalPrompt( params: acp.PromptRequest, source: string, canStart: () => Promise = async () => true, + insertion?: UserMessageInsertion, ): Promise { - // A prompt can outlive its turn while post-turn cleanup runs. Starting a - // control-triggered turn during that window would run two prompts on the - // same session, so wait for the current prompt to drain first. - const previousPrompt = this.activePrompts.get(params.sessionId); - await previousPrompt?.completion; + // Takes this session's place in the shared turn-start FIFO before anything else runs, so + // no other starter can begin between this check and the turn actually starting. This + // hands the reservation to `prompt()` below rather than letting it self-acquire one, so + // it releases only once `prompt()` truly finishes (not when this function's own steer + // promise resolves early, on the "a turn was started" success path). + const reservation = this.acquireTurnStartReservation(params.sessionId); + if (reservation.needsWait) { + await reservation.wait; + } if (this.sessionIsClosing(params.sessionId)) { + reservation.release(); throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); } if (!await canStart()) { + reservation.release(); return false; } @@ -2195,7 +2725,8 @@ export class CodexAcpServer { // steer immediately ("a turn was started") and let prompt() finish the // turn in the background. resolve(true); - }); + }, insertion, reservation); + void promptDone.finally(() => reservation.release()); promptDone.then( (response) => { if (!turnStarted && response.stopReason === "cancelled") { @@ -2247,7 +2778,7 @@ export class CodexAcpServer { return null; } if (sessionState.currentTurnId) { - return sessionState.currentTurnId; + return codexRunningTurnId(sessionState, sessionState.currentTurnId); } const pendingTurnStart = this.pendingTurnStarts.get(sessionState.sessionId); @@ -2313,6 +2844,14 @@ export class CodexAcpServer { }; } + /** The v2 `configOptions` field for session responses (`session/new`, `session/resume`). */ + private createSessionConfigOptionsResponseV2(sessionState: SessionState): { + configOptions?: Array; + } { + const {configOptions} = this.createSessionConfigOptionsResponse(sessionState); + return configOptions ? {configOptions: toV2ConfigOptions(configOptions)} : {}; + } + private isSessionConfigEnabled(): boolean { // Temporarily disabled for JB IDEs 2026.1 due to issues in session_config (LLM-28118) return !isJetBrains2026_1Client(this.clientInfo); @@ -2410,13 +2949,17 @@ export class CodexAcpServer { } private async getOrCreateSessionWithHistory( - request: acp.LoadSessionRequest + request: WithAcpMcpServers ): Promise<{ sessionId: SessionId; modelState: LegacySessionModelState; modeState: SessionModeState; thread: Thread; - history: AsyncIterable; + history: AsyncIterable; + sessionState: SessionState; + // Settles the baseline tracker's `ready` gate; resolve after `streamThreadHistory` so a + // live goal-turn frame Codex fires right after resume/load never races ahead of replay. + settleTrackerReady: (state: SessionState | null) => void; }> { const requestedSessionGeneration = this.beginSessionOpen(request.sessionId); const loadStartedAt = performance.now(); @@ -2438,6 +2981,9 @@ export class CodexAcpServer { logger.log(`Load existing session: ${request.sessionId}...`); let subscribed = false; + // Registered before `thread/resume` is even sent; see `startCodexTurnTracker`. + const [trackerReady, settleTrackerReady] = createDeferred(); + this.startCodexTurnTracker(request.sessionId, trackerReady); let sessionMetadata: SessionMetadataWithThread; let knownAccount: KnownAccount | "pending"; // The app-server that loads the thread, read in the closure that loads it. A crash after the load makes the @@ -2462,8 +3008,13 @@ export class CodexAcpServer { } knownAccount = await settledWithin(authorization, graceLeftMs()); } catch (err) { + settleTrackerReady(null); if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); + } else { + // `thread/resume` never subscribed the connection, so there is nothing for + // `cleanupStaleSessionOpen` to unsubscribe; just drop the local handler. + this.codexAcpClient.discardSessionSubscription(request.sessionId); } throw err; } @@ -2474,12 +3025,14 @@ export class CodexAcpServer { try { authState = await this.getAuthStateForProvider(authProvider, knownAccount === "pending" ? "unavailable" : knownAccount); } catch (err) { + settleTrackerReady(null); if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); } throw err; } if (!this.sessionOpenCanInstall(sessionId, requestedSessionGeneration)) { + settleTrackerReady(null); subscribed = false; await this.closeStaleSessionOpen(sessionId, requestedSessionGeneration); } @@ -2495,6 +3048,8 @@ export class CodexAcpServer { agentMode: AgentMode.getInitialAgentMode(), collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, + interruptTurnId: null, + codexReportedRunningTurnId: null, lastTokenUsage: null, totalTokenUsage: null, promptTokenUsage: null, @@ -2523,6 +3078,7 @@ export class CodexAcpServer { asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), toolCallReports: this.reportingConnection.reports, + openToolCalls: new CodexSessionToolCalls(), }; sessionState.titleGen = new TitleGenerator( this.codexAcpClient.appServerClient, @@ -2557,6 +3113,8 @@ export class CodexAcpServer { modeState: sessionModeState, thread: thread, history: sessionMetadata.history, + sessionState: sessionState, + settleTrackerReady: settleTrackerReady, }; } @@ -2564,7 +3122,11 @@ export class CodexAcpServer { * Sends the history of a loaded session one page of items at a time. The * adapter keeps only the current page, not the whole history. */ - private async streamThreadHistory(sessionId: string, thread: Thread, history: AsyncIterable): Promise { + private async streamThreadHistory( + sessionId: string, + thread: Thread, + history: AsyncIterable, + ): Promise { const session = new ACPSessionConnection(this.connection, sessionId); const sessionState = this.getSessionState(sessionId); const generation = this.getSessionGeneration(sessionId); @@ -2573,8 +3135,14 @@ export class CodexAcpServer { const first = await pages.next(); const firstPage = first.done ? [] : first.value; // The first user message of the first page names the session. - await this.publishThreadHistoryTitle(session, sessionState, thread, firstPage); - const itemPages = untilSessionClose(pagesStartingWith(firstPage, pages), isOpen); + await this.publishThreadHistoryTitle(session, sessionState, thread, firstPage.map(entry => entry.item)); + const entryPages = pagesStartingWith(firstPage, pages); + // Hiding the `/review` reviewer prompt is a v2-only change (user decision): v1 keeps + // showing it, as it always has. + const itemPages = untilSessionClose( + this.protocolVersion === 2 ? withoutReviewerPrompts(entryPages) : itemsOfEntries(entryPages), + isOpen, + ); if (clientSupportsSubagents(this.clientCapabilities)) { await this.streamNativeThreadHistory( sessionId, @@ -2586,16 +3154,45 @@ export class CodexAcpServer { ); return; } + const startedReplayMessages = new Set(); for await (const items of itemPages) { for (const item of items) { if (!isOpen()) throw new SessionClosedDuringLoadError(); for (const update of await this.createHistoryUpdates(item, sessionState)) { + if (this.protocolVersion === 2) { + await this.sendReplayMessageStart(session, update, startedReplayMessages); + } await session.update(update); } } } } + /** + * On v2, replay reconstructing a message from its beginning via chunks MUST first send a + * whole-message update with `content: []` for the same id, clearing any content the client + * already holds for it (`session-setup.mdx`). No-op for chunks with no messageId: those get + * a fresh random id downstream instead (`toV2SessionUpdate`), so there is nothing to key on + * ahead of time. + */ + private async sendReplayMessageStart( + session: ACPSessionConnection, + update: UpdateSessionEvent, + started: Set, + ): Promise { + const kind = replayMessageStartKind(update.sessionUpdate); + const messageId = kind ? (update as {messageId?: string | null}).messageId : null; + if (!kind || !messageId) { + return; + } + const key = `${kind}:${messageId}`; + if (started.has(key)) { + return; + } + started.add(key); + await session.startReplayMessage(kind, messageId); + } + private async streamNativeThreadHistory( sessionId: string, itemPages: AsyncIterable, @@ -2606,6 +3203,8 @@ export class CodexAcpServer { ): Promise { const session = new ACPSessionConnection(this.connection, sessionId); const announced = new Map(); + // v2 only (this path also serves v1's native replay, unchanged there). + const startedReplayMessages = new Set(); for await (const items of itemPages) { for (const item of items) { if (!isOpen()) throw new SessionClosedDuringLoadError(); @@ -2699,6 +3298,9 @@ export class CodexAcpServer { // The activity items above replay the lifecycle of a spawn. A control call is a tool call, as in the live session. if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") continue; for (const update of await this.createHistoryUpdates(item, sessionState)) { + if (this.protocolVersion === 2) { + await this.sendReplayMessageStart(session, update, startedReplayMessages); + } await session.update(update); } } @@ -2835,7 +3437,7 @@ export class CodexAcpServer { case "reasoning": return this.createReasoningUpdates(item); case "fileChange": - return [renderer.render(FileChangeReporter.started(item, renderer.capabilities.air.diffPatch))]; + return [renderer.render(FileChangeReporter.started(item, renderer.capabilities.diffPatchFormat))]; case "commandExecution": return CommandReporter.history(item).map(facts => renderer.render(facts)); case "mcpToolCall": @@ -2865,7 +3467,10 @@ export class CodexAcpServer { private createUserMessageUpdates(item: ThreadItem & { type: "userMessage" }): UpdateSessionEvent[] { const updates: UpdateSessionEvent[] = []; - const messageId = item.id; + // On v2, a message inserted via `session/prompt` is replayed under the id the client + // provided then (`clientId`), so it round-trips as the same message on reconnect; v1 has + // no such client-minted id and keeps using Codex's own item id. + const messageId = this.protocolVersion === 2 ? (item.clientId ?? item.id) : item.id; const contentBlocks = item.content.map(input => this.userInputToContentBlocks(input)); const attachmentUris = new Set(contentBlocks.flatMap((blocks, index) => item.content[index]?.type === "text" @@ -2896,6 +3501,9 @@ export class CodexAcpServer { ): UpdateSessionEvent { return { sessionUpdate: "agent_message_chunk", + // v2 requires a replayed message to carry a stable id; use the persisted item id. + // v1 keeps no id here, to stay byte-identical with existing clients. + ...(this.protocolVersion === 2 ? {messageId: item.id} : {}), content: { type: "text", text: `${entered ? "Entered" : "Exited"} review mode: ${item.review}`, @@ -2981,7 +3589,7 @@ export class CodexAcpServer { } private resolveSessionMcpServers( - mcpServers: Array, + mcpServers: Array, recoverFromStartup: boolean, ): Array { // Explicit MCP servers from the request are the primary source of truth for the session. @@ -3018,6 +3626,7 @@ export class CodexAcpServer { const clientCancelSignal = new Promise((resolve) => { resolveClientCancelSignal = resolve; }); + const interactionAbortController = new AbortController(); let completed = false; let closeRequested = false; @@ -3026,8 +3635,11 @@ export class CodexAcpServer { closeSignal, cancelSignal, signal: abortController.signal, + interactionSignal: interactionAbortController.signal, + cancelRequested: false, currentTurn: null, requestCancel: () => { + activePrompt.abortInteractions(); if (abortController.signal.aborted) { return; } @@ -3042,6 +3654,9 @@ export class CodexAcpServer { activePrompt.requestCancel(); resolveCloseSignal(null); }, + abortInteractions: () => { + interactionAbortController.abort(); + }, complete: () => { if (completed) { return; @@ -3092,7 +3707,7 @@ export class CodexAcpServer { if (!turn) { return; } - void this.requestTurnInterrupt(turn, "Cancel"); + void this.requestTurnInterrupt(sessionState, turn.threadId, turn.turnId, "Cancel"); }; if (signal.aborted) { @@ -3112,7 +3727,61 @@ export class CodexAcpServer { return {promise, resolve}; } + /** + * Takes this session's place in the shared per-session turn-start FIFO. Every codex-acp turn + * starter (v1 `session/prompt`, v2 `session/prompt`, the goal-continuation and steering + * fallbacks) calls this synchronously, before its first `await`, so no two starters can ever + * decide to start a turn based on the same "is something running" snapshot: whichever calls + * this first is queued ahead. `wait` resolves once the previous reservation on this session + * releases; the caller must call `release()` exactly once it is safe for the next queued + * starter to become visibly active (which may be later than when this starter's own request + * is answered). + */ + private acquireTurnStartReservation(sessionId: string): TurnStartReservation { + const previousSlot = this.turnStartQueueTail.get(sessionId); + const needsWait = previousSlot !== undefined && !previousSlot.settled; + const wait = needsWait ? previousSlot!.promise : Promise.resolve(); + const slot: {promise: Promise; settled: boolean} = {promise: Promise.resolve(), settled: false}; + let release: () => void = () => {}; + slot.promise = new Promise((resolve) => { + release = () => { + slot.settled = true; + resolve(); + }; + }); + this.turnStartQueueTail.set(sessionId, slot); + return {wait, needsWait, release}; + } + + /** + * Registers a callback that aborts a v2 `session/prompt` still queued behind a running turn. + * Returns an unregister function the caller must invoke once it stops waiting (whether it was + * cancelled or reached the front of the queue on its own). + */ + private registerQueuedV2PromptCanceller(sessionId: string, canceller: () => void): () => void { + let cancellers = this.queuedV2PromptCancellers.get(sessionId); + if (!cancellers) { + cancellers = new Set(); + this.queuedV2PromptCancellers.set(sessionId, cancellers); + } + cancellers.add(canceller); + return () => cancellers!.delete(canceller); + } + + /** Aborts every v2 `session/prompt` currently queued (not yet inserted) for a session. */ + private cancelQueuedV2Prompts(sessionId: string): void { + const cancellers = this.queuedV2PromptCancellers.get(sessionId); + if (!cancellers) { + return; + } + for (const canceller of cancellers) { + canceller(); + } + cancellers.clear(); + } + private async interruptPromptTurn( + sessionState: SessionState, turn: { threadId: string, turnId: string }, requestName: "Cancel" | "Close", ): Promise { @@ -3121,7 +3790,7 @@ export class CodexAcpServer { turnId: turn.turnId, }); try { - await this.requestTurnInterrupt(turn, requestName); + await this.requestTurnInterrupt(sessionState, turn.threadId, turn.turnId, requestName); } finally { this.codexAcpClient.resolveTurnInterrupted({ threadId: turn.threadId, @@ -3130,48 +3799,64 @@ export class CodexAcpServer { } } + /** + * Sends `turn/interrupt` and retries it against the registration race: right after a + * turn (or review child turn) is started, Codex can briefly answer "no active turn to + * interrupt", and once it registers a later turn under a different id, "expected active turn + * id but found ". Both are retried, with the id recomputed on every + * attempt so a `turn/started` that arrives between retries is picked up. + */ private async requestTurnInterrupt( - turn: { threadId: string, turnId: string }, + sessionState: SessionState, + threadId: string, + completionTurnId: string, requestName: "Cancel" | "Close", ): Promise { - if (this.recovery !== null && !this.turnAppServerIsLive(turn.threadId)) { + if (this.recovery !== null && !this.turnAppServerIsLive(threadId)) { // The turn died with its app-server. A restarted app-server has no such turn. - logger.log(`${requestName} - turnInterrupt skipped: the app-server of the turn is gone`, {sessionId: turn.threadId}); + logger.log(`${requestName} - turnInterrupt skipped: the app-server of the turn is gone`, {sessionId: threadId}); return; } + let turnId = codexRunningTurnId(sessionState, completionTurnId); for (let attempt = 0; ; attempt++) { try { await this.runWithProcessCheck(() => this.codexAcpClient.turnInterrupt({ - threadId: turn.threadId, - turnId: turn.turnId, + threadId, + turnId, })); logger.log(`${requestName} - turnInterrupt succeeded`, { - sessionId: turn.threadId, - currentTurnId: turn.turnId, + sessionId: threadId, + currentTurnId: turnId, }); return; } catch (err) { - const retryDelay = requestName === "Cancel" - && isNoActiveTurnError(err) - && attempt < NO_ACTIVE_TURN_RETRY_DELAYS_MS.length - && this.activePrompts.has(turn.threadId) - ? NO_ACTIVE_TURN_RETRY_DELAYS_MS[attempt]! - : null; - if (retryDelay === null) { + const promptStillActive = this.activePrompts.has(threadId); + const mismatch = parseExpectedActiveTurnMismatch(err); + const isMismatch = mismatch !== null + && mismatch.expected === sessionState.currentTurnId + && mismatch.found !== ""; + const retryable = promptStillActive + && (isNoActiveTurnError(err) || isMismatch) + && attempt < NO_ACTIVE_TURN_RETRY_DELAYS_MS.length; + if (!retryable) { logger.error(`${requestName} - turnInterrupt failed`, err); return; } - // The cancel raced the turn's registration in Codex: the prompt + // The interrupt raced the turn's registration in Codex: the prompt // is still in flight, so the turn is about to become - // interruptible. Dropping the cancel here would let the turn run + // interruptible. Dropping the interrupt here would let the turn run // to completion and answer `end_turn`, which ACP forbids after a // `session/cancel`. + await new Promise(resolve => setTimeout(resolve, NO_ACTIVE_TURN_RETRY_DELAYS_MS[attempt]!)); + // Recompute after the wait: a `turn/started` may have landed in the meantime, and + // `interruptTurnId` always wins once it is set. Otherwise fall back to the id Codex + // just reported as active, or to the id we started with. + turnId = sessionState.interruptTurnId ?? (isMismatch ? mismatch!.found : completionTurnId); logger.log(`${requestName} - turn not interruptible yet, retrying`, { - sessionId: turn.threadId, - currentTurnId: turn.turnId, + sessionId: threadId, + currentTurnId: turnId, attempt, }); - await new Promise(resolve => setTimeout(resolve, retryDelay)); } } } @@ -3182,8 +3867,8 @@ export class CodexAcpServer { return sessionState === undefined ? this.recovery.isReady() : this.recovery.sessionIsLive(sessionState); } - private interruptLateStartedTurn(turn: { threadId: string, turnId: string }): void { - void this.interruptPromptTurn(turn, "Close"); + private interruptLateStartedTurn(sessionState: SessionState, turn: { threadId: string, turnId: string }): void { + void this.interruptPromptTurn(sessionState, turn, "Close"); } private promptShouldStop(sessionId: string, activePrompt: ActivePrompt): boolean { @@ -3211,7 +3896,7 @@ export class CodexAcpServer { }); } try { - await this.requestTurnInterrupt({threadId: sessionState.sessionId, turnId}, requestName); + await this.requestTurnInterrupt(sessionState, sessionState.sessionId, turnId, requestName); } finally { if (resolveInterruptedTurn) { this.codexAcpClient.resolveTurnInterrupted({ @@ -3248,10 +3933,232 @@ export class CodexAcpServer { return turnId; } + /** + * v2 `session/prompt`: answers `{messageId}` once the user message is inserted and lets the + * turn run on in the background. A Codex prompt is inserted when Codex records its user + * message; a locally handled command has no Codex turn, so it is inserted right away. + */ + async promptV2(params: acpV2.PromptRequest, signal?: AbortSignal): Promise { + const sessionId = params.sessionId; + const request = toV1PromptRequest(params); + const sessionState = this.getSessionState(sessionId); + if (this.sessionIsClosing(sessionId)) { + throw RequestError.invalidRequest(`Session ${sessionId} is closing`); + } + // A prompt overlapping a running one is queued behind it rather than rejected: take this + // session's place in the shared turn-start FIFO now (before any await), then wait. Nothing + // observable (response, user message, states) happens until this prompt reaches the front. + const reservation = this.acquireTurnStartReservation(sessionId); + if (reservation.needsWait) { + // `session/cancel`/`session/close` drop this prompt while it waits here: race the + // FIFO wait against a cancellation signal so the client sees `-32800` right away, + // instead of only once the running turn ahead of it actually finishes. A + // `$/cancel_request` for this specific request goes through the same canceller, so it + // only drops this prompt and leaves the rest of the queue untouched. + let cancelled = false; + let markCancelled: () => void = () => { cancelled = true; }; + const cancelSignal = new Promise((resolve) => { + markCancelled = () => { cancelled = true; resolve(); }; + }); + const unregister = this.registerQueuedV2PromptCanceller(sessionId, markCancelled); + const onRequestCancelled = () => markCancelled(); + if (signal) { + if (signal.aborted) { + onRequestCancelled(); + } else { + signal.addEventListener("abort", onRequestCancelled, {once: true}); + } + } + await Promise.race([reservation.wait, cancelSignal]); + unregister(); + signal?.removeEventListener("abort", onRequestCancelled); + if (cancelled) { + // Still release in the FIFO's own order once it is actually this prompt's turn, + // so anything queued behind it does not start while the current turn is still + // being interrupted. + void reservation.wait.then(() => reservation.release()); + throw RequestError.requestCancelled(undefined, `Session ${sessionId} was cancelled before the prompt was inserted`); + } + } + const promptKind = this.availableCommands.classifyPrompt(request.prompt); + const messageId = randomUUID(); + const session = new ACPSessionConnection(this.connection, sessionId); + this.v2PromptsInFlight.add(sessionId); + const sendState = async (state: acpV2.StateUpdate) => { + try { + await session.updateState(state); + } catch (error) { + logger.error(`Failed to send the '${state.state}' state for session ${sessionId}`, error); + } + }; + return await new Promise((resolve, reject) => { + let running: Promise | null = null; + let inserted = false; + // Set from `onTurnAdopted` when `turn/start` steers this prompt into a turn that was + // already running unowned: that turn's `running` already went out before this + // prompt existed, so this prompt must not send a second one. + let turnWasAdopted = false; + let startedTurn: {threadId: string, turnId: string} | null = null; + let requestCancelHandled = false; + // A `$/cancel_request` for a prompt whose `turn/start` was sent but has not landed + // yet still has a pending request to answer: interrupt the turn it started (like + // v1's `observePromptRequestCancellation`) and drop it with `-32800`. A turn this + // prompt only adopted belongs to someone else (e.g. a Codex goal turn) and must + // keep running -- only this request is dropped, still with `-32800`; whatever that + // turn actually finishes with is reported normally once `run()` settles below. + const dropPendingRequest = () => { + if (inserted || requestCancelHandled) { + return; + } + requestCancelHandled = true; + if (!turnWasAdopted && startedTurn !== null) { + void this.requestTurnInterrupt(sessionState, startedTurn.threadId, startedTurn.turnId, "Cancel"); + } + reject(RequestError.requestCancelled(undefined, "The prompt request was cancelled before it was inserted")); + }; + if (signal) { + if (signal.aborted) { + dropPendingRequest(); + } else { + signal.addEventListener("abort", dropPendingRequest, {once: true}); + } + } + const onInserted = async () => { + inserted = true; + try { + for (const block of request.prompt) { + await session.update(createUserMessageChunk(block, messageId)); + } + } catch (error) { + logger.error(`Failed to send the user message for session ${sessionId}`, error); + } + resolve({messageId}); + if (turnWasAdopted) { + return; + } + // Report `running` only after the response has been queued, as the spec's sequence + // shows (response, user message, then `running`). Awaiting it here holds back the + // turn's later updates until it is sent. + running = new Promise(resolveTimer => setTimeout(resolveTimer, 0)) + .then(() => sendState({state: "running"})); + await running; + }; + const run = async () => { + if (promptKind.kind === "localCommand") { + await onInserted(); + return await this.prompt(request, undefined, undefined, undefined, reservation); + } + return await this.prompt(request, undefined, undefined, { + clientUserMessageId: messageId, + onInserted, + onSyntheticInserted: async (syntheticId, prompt) => { + try { + for (const block of prompt) { + await session.update(createUserMessageChunk(block, syntheticId)); + } + } catch (error) { + logger.error(`Failed to send the synthetic user message for session ${sessionId}`, error); + } + }, + onTurnAdopted: () => { + turnWasAdopted = true; + }, + onTurnStarted: (turn) => { + startedTurn = turn; + }, + }, reservation); + }; + run().then( + async (response) => { + this.v2PromptsInFlight.delete(sessionId); + if (!inserted) { + const notInsertedMessage = "The prompt ended before Codex recorded the user message"; + // A `cancelled` v1 stop reason means the adopted turn was interrupted + // (e.g. by `session/cancel`) before this prompt's input landed: it was + // never inserted, so it is dropped with `-32800` like a queued prompt. + reject(response.stopReason === "cancelled" + ? RequestError.requestCancelled(undefined, notInsertedMessage) + : RequestError.internalError(undefined, notInsertedMessage)); + // Codex dropped the steered input before the adopted turn ended: that + // turn's `running` still needs exactly one matching `idle`, and nothing + // else will send it now that this prompt is no longer in flight. + if (turnWasAdopted) { + await sendState(toV2IdleState(response)); + } + return; + } + if (running !== null) { + await running; + } + // The session takes the next prompt before `idle` goes out, so a client that + // prompts again as soon as it sees `idle` is not rejected as overlapping. + // What v1 would have answered with ends the v2 turn. + await sendState(toV2IdleState(response)); + }, + async (error: unknown) => { + if (!inserted) { + this.v2PromptsInFlight.delete(sessionId); + reject(error); + if (turnWasAdopted) { + await sendState(toV2IdleState(this.failedPromptResponse(sessionId))); + } + return; + } + // Past insertion the request is answered, so the failure is told as agent + // text (unless the turn already sent it) and the turn still ends with `idle`. + logger.error(`Prompt for session ${sessionId} failed after it was inserted`, error); + if (running !== null) { + await running; + } + if (!failureWasShownAsMessage(error)) { + try { + await session.update(createAgentTextMessageChunk(postInsertionFailureText( + error, + promptKind.kind === "localCommand" ? promptKind.name : undefined, + ))); + } catch (sendError) { + logger.error(`Failed to send the prompt failure for session ${sessionId}`, sendError); + } + } + this.v2PromptsInFlight.delete(sessionId); + await sendState(toV2IdleState(this.failedPromptResponse(sessionId))); + }, + ).finally(() => { + signal?.removeEventListener("abort", dropPendingRequest); + reservation.release(); + }); + }); + } + async prompt( params: acp.PromptRequest, signal?: AbortSignal, onTurnStarted?: () => void, + insertion?: UserMessageInsertion, + reservation?: TurnStartReservation, + ): Promise { + // Callers that need to gate additional checks (closing, canStart) atomically with the + // turn-start slot acquire their own reservation and pass it in; otherwise this call is + // the v1 entry point and takes the session's turn-start slot itself. + const ownsReservation = reservation === undefined; + const activeReservation = reservation ?? this.acquireTurnStartReservation(params.sessionId); + if (activeReservation.needsWait) { + await activeReservation.wait; + } + try { + return await this.promptAfterReservation(params, signal, onTurnStarted, insertion); + } finally { + if (ownsReservation) { + activeReservation.release(); + } + } + } + + private async promptAfterReservation( + params: acp.PromptRequest, + signal?: AbortSignal, + onTurnStarted?: () => void, + insertion?: UserMessageInsertion, ): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); @@ -3274,6 +4181,7 @@ export class CodexAcpServer { sessionState.currentTurnId = null; // A prompt that starts no model work, such as /status or a cancellation before the turn, used no tokens. sessionState.promptTokenUsage = null; + sessionState.interruptTurnId = null; const activePrompt = this.trackActivePrompt(params.sessionId); let pendingTurnStart: PendingTurnStart | null = null; const ensurePendingTurnStart = (): PendingTurnStart => { @@ -3299,6 +4207,17 @@ export class CodexAcpServer { const subagentWaitSignal = this.recovery === null ? activePrompt.signal : AbortSignal.any([activePrompt.signal, appServerLost.signal]); + let pendingInsertion = insertion; + // Synthetic turns codex-acp starts itself inside this same prompt (the plan-implementation + // follow-up, a `/goal` continuation) each mint their own id and register here, so their + // userMessage can be told apart from the original prompt's once it lands. + const pendingSyntheticInsertions = new Map Promise>(); + const registerSyntheticInsertion = (clientUserMessageId: string, prompt: acp.ContentBlock[]): void => { + if (insertion === undefined) { + return; + } + pendingSyntheticInsertions.set(clientUserMessageId, () => insertion.onSyntheticInserted(clientUserMessageId, prompt)); + }; const clearRecoveredSessionFailure = async (handler: CodexEventHandler): Promise => { await handler.completeSuccessfulTurn(sessionState.currentTurnId); const current = sessionState.sessionFailure; @@ -3359,14 +4278,14 @@ export class CodexAcpServer { const approvalHandler = new CodexApprovalHandler( interactionConnection, permissionContext, - activePrompt.signal, + activePrompt.interactionSignal, toolCallRenderer, ); const elicitationHandler = new CodexElicitationHandler( interactionConnection, permissionContext, this.clientCapabilities, - activePrompt.signal, + activePrompt.interactionSignal, toolCallRenderer, ); promptElicitations = elicitationHandler; @@ -3374,11 +4293,37 @@ export class CodexAcpServer { permissionContext.handleNotification(event); await elicitationHandler.handleNotification(event); }; + const resolvePendingInsertion = async (): Promise => { + if (pendingInsertion === undefined) { + return; + } + const {onInserted} = pendingInsertion; + pendingInsertion = undefined; + await onInserted(); + }; await promptClient.subscribeToSessionEvents(params.sessionId, async (event) => { + // Tracks turns this prompt doesn't own too (a `/goal` continuation after this + // prompt's own turn already went idle): the same subscription keeps receiving + // notifications for as long as no later prompt replaces it. + await this.trackCodexTurnStart(sessionState, event); + await this.trackSteerLanding(sessionState, event); + if (pendingInsertion !== undefined + && isInsertedUserMessage(event, params.sessionId, pendingInsertion.clientUserMessageId)) { + await resolvePendingInsertion(); + } else { + for (const [clientUserMessageId, resolveSynthetic] of pendingSyntheticInsertions) { + if (isInsertedUserMessage(event, params.sessionId, clientUserMessageId)) { + pendingSyntheticInsertions.delete(clientUserMessageId); + await resolveSynthetic(); + break; + } + } + } await observeInteraction(event); if (!promptNotificationsActive) { await promptEventHandler.handleSessionScopedNotification(event); + await this.trackCodexTurnCompletion(sessionState, event); return; } const completesActiveTurn = event.method === "turn/completed" @@ -3390,6 +4335,7 @@ export class CodexAcpServer { // the causal boundary so a queued late error cannot enter the completed turn's buffer. promptNotificationsActive = false; } + await this.trackCodexTurnCompletion(sessionState, event); }, approvalHandler, elicitationHandler, @@ -3410,14 +4356,20 @@ export class CodexAcpServer { onTurnStarted: (turnId, threadId) => { const turn = {threadId, turnId}; activePrompt.currentTurn = turn; + insertion?.onTurnStarted?.(turn); if (this.promptShouldStop(params.sessionId, activePrompt)) { - this.interruptLateStartedTurn(turn); + this.interruptLateStartedTurn(sessionState, turn); return; } sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); onTurnStarted?.(); }, + ...(insertion === undefined ? {} : { + onCommandAccepted: () => { + void resolvePendingInsertion(); + }, + }), setConfigOption: async (configId, value) => { await this.applySessionConfigOption(sessionState, { sessionId: sessionState.sessionId, @@ -3482,10 +4434,6 @@ export class CodexAcpServer { }; } - const effectiveParams = commandResult.prompt === undefined - ? params - : {...params, prompt: commandResult.prompt}; - if (this.sessionIsClosing(params.sessionId)) { return cancelledPromptResponse(); } @@ -3502,7 +4450,7 @@ export class CodexAcpServer { }); } - if (!sessionState.supportedInputModalities.includes("image") && effectiveParams.prompt.some(b => b.type === "image")) { + if (!sessionState.supportedInputModalities.includes("image") && params.prompt.some(b => b.type === "image")) { throw RequestError.invalidRequest("The current model does not support image input"); } const agentMode = sessionState.agentMode; @@ -3512,9 +4460,13 @@ export class CodexAcpServer { ); this.beginPromptTokenUsage(sessionState); ensurePendingTurnStart(); + // Snapshot right before dispatch (no await in between): if a turn is already + // running here, it is unowned (this prompt hasn't started one yet) and already sent + // its own `running`. + const priorRunningTurnId = sessionState.codexReportedRunningTurnId; const sendPromptPromise = this.runOnAppServer(promptGeneration, promptClient, () => promptClient.sendPrompt( - effectiveParams, + params, agentMode, modelId, serviceTier, @@ -3524,15 +4476,20 @@ export class CodexAcpServer { (turnId) => { const turn = {threadId: params.sessionId, turnId}; activePrompt.currentTurn = turn; + insertion?.onTurnStarted?.(turn); if (this.promptShouldStop(params.sessionId, activePrompt)) { - this.interruptLateStartedTurn(turn); + this.interruptLateStartedTurn(sessionState, turn); return; } sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); + if (priorRunningTurnId !== null && turnId === priorRunningTurnId) { + insertion?.onTurnAdopted?.(); + } onTurnStarted?.(); }, () => this.promptShouldStop(params.sessionId, activePrompt), + insertion?.clientUserMessageId, )); void sendPromptPromise.catch((err) => { if (this.activePrompts.get(params.sessionId) !== activePrompt) { @@ -3594,10 +4551,13 @@ export class CodexAcpServer { const approved = await this.requestPlanImplementationPermission( sessionState, completedPlan, - activePrompt.signal, + activePrompt.interactionSignal, interactionConnection, ); - if (this.promptShouldStop(params.sessionId, activePrompt)) { + // `cancelRequested` catches plain `session/cancel`, which doesn't abort `signal` + // (that would also change pre-turn prompt flow); without it this branch would + // fall through to `end_turn` instead of `cancelled`. + if (this.promptShouldStop(params.sessionId, activePrompt) || activePrompt.cancelRequested) { return cancelledPromptResponse(); } if (approved && !this.promptShouldStop(params.sessionId, activePrompt)) { @@ -3614,6 +4574,13 @@ export class CodexAcpServer { }; activePrompt.currentTurn = null; sessionState.currentTurnId = null; + sessionState.interruptTurnId = null; + // This second turn stays inside the original prompt's running…idle pair, so it + // gets its own minted id rather than reusing the first turn's. + const implementationClientUserMessageId = insertion !== undefined ? randomUUID() : undefined; + if (implementationClientUserMessageId !== undefined) { + registerSyntheticInsertion(implementationClientUserMessageId, implementationRequest.prompt); + } const implementationPromise = this.runOnAppServer(promptGeneration, promptClient, () => promptClient.sendPrompt( implementationRequest, @@ -3627,7 +4594,7 @@ export class CodexAcpServer { const turn = {threadId: params.sessionId, turnId}; activePrompt.currentTurn = turn; if (this.promptShouldStop(params.sessionId, activePrompt)) { - this.interruptLateStartedTurn(turn); + this.interruptLateStartedTurn(sessionState, turn); return; } sessionState.currentTurnId = turnId; @@ -3637,6 +4604,7 @@ export class CodexAcpServer { promptNotificationsActive = true; }, () => this.promptShouldStop(params.sessionId, activePrompt), + implementationClientUserMessageId, ), ); void implementationPromise.catch((err) => { @@ -3715,10 +4683,14 @@ export class CodexAcpServer { sessionState.titleGen.onTurnCompleted(promptText); } - await this.publishFallbackSessionTitle( - sessionState, - this.createPromptFallbackTitle(params.prompt), - ); + // On v2, a prompt whose user message was never recorded (`pendingInsertion` still set) + // never happened from the client's view, so it must not leave a title behind either. + if (pendingInsertion === undefined) { + await this.publishFallbackSessionTitle( + sessionState, + this.createPromptFallbackTitle(params.prompt), + ); + } return { stopReason: "end_turn", @@ -3809,6 +4781,7 @@ export class CodexAcpServer { disposePromptRequestCancellation(); removeAppServerLossListener(); sessionState.currentTurnId = null; + sessionState.interruptTurnId = null; const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId); if (registeredPendingTurnStart !== undefined) { this.pendingTurnStarts.delete(params.sessionId); @@ -3901,6 +4874,19 @@ export class CodexAcpServer { }; } + /** The v1-shaped result of a prompt that failed after insertion, for its v2 `idle`. */ + private failedPromptResponse(sessionId: string): acp.PromptResponse { + const sessionState = this.sessions.get(sessionId); + if (sessionState === undefined) { + return {stopReason: "end_turn"}; + } + return { + stopReason: "end_turn", + usage: this.buildPromptUsage(sessionState), + _meta: this.buildQuotaMeta(sessionState), + }; + } + private terminalFailurePromptResponse( sessionState: SessionState, eventHandler: CodexEventHandler, @@ -3999,11 +4985,95 @@ export class CodexAcpServer { return; } + // Abort outbound permission/elicitation requests synchronously, before awaiting the turn + // interrupt below (which can itself wait on a pending turn start). Mark cancelRequested so + // the plan-review branch can detect this cancellation even though it doesn't abort `signal`. + const activePrompt = this.activePrompts.get(params.sessionId); + if (activePrompt) { + activePrompt.cancelRequested = true; + activePrompt.abortInteractions(); + } + + // Drop every v2 prompt still queued (not yet inserted) before interrupting the running + // turn, so their `-32800` responses do not wait on the interrupt completing. No-op on v1. + this.cancelQueuedV2Prompts(params.sessionId); // After turnInterrupt(), Codex will send turn/completed, which naturally completes awaitTurnCompleted(). await this.interruptSessionTurn(sessionState, "Cancel", false); } } +/** A buffered reviewer prompt candidate is shown once its turn has this many items. */ +const REVIEWER_PROMPT_CANDIDATE_MAX_ITEMS = 100; + +/** + * The items of `pages` without the reviewer prompts of `/review` runs (user decision: v2 only, v1 + * keeps showing them). A `/review` run persists its reviewer prompt as the first item of its own + * turn T, which Codex lists just before the review turn P (P's first item is `enteredReviewMode`). + * T is never shown live, and there is no way to distinguish it from an ordinary preceding turn + * except that T is minted *after* P: its UUIDv7 turn id sorts higher. Only a turn that can be T is + * held back, until the first item of the next turn decides. + */ +async function* withoutReviewerPrompts(pages: AsyncIterable): AsyncGenerator { + let turnId: string | null = null; + let candidate: {turnId: string; items: ThreadItem[]} | null = null; + for await (const page of pages) { + const items: ThreadItem[] = []; + for (const entry of page) { + const item = entry.item; + const turnStarts = entry.turnId !== turnId; + turnId = entry.turnId; + if (candidate !== null && turnStarts) { + const hidden = item.type === "enteredReviewMode" + && isUuidV7(candidate.turnId) && isUuidV7(entry.turnId) && candidate.turnId > entry.turnId; + items.push(...(hidden ? candidate.items.slice(1) : candidate.items)); + candidate = null; + } + if (turnStarts && item.type === "userMessage" && item.clientId === null) { + candidate = {turnId: entry.turnId, items: [item]}; + continue; + } + if (candidate === null) { + items.push(item); + continue; + } + candidate.items.push(item); + // A turn with its own messages is not a reviewer prompt turn. + if (item.type === "agentMessage" || item.type === "userMessage" + || candidate.items.length >= REVIEWER_PROMPT_CANDIDATE_MAX_ITEMS) { + items.push(...candidate.items); + candidate = null; + } + } + if (items.length > 0) yield items; + } + if (candidate !== null) yield candidate.items; +} + +/** The items of the entry pages of `pages`. */ +async function* itemsOfEntries(pages: AsyncIterable): AsyncGenerator { + for await (const page of pages) { + yield page.map(entry => entry.item); + } +} + +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); +} + +/** The whole-message kind a replayed chunk update restarts, or `null` if it isn't a chunk. */ +function replayMessageStartKind(sessionUpdate: UpdateSessionEvent["sessionUpdate"]): ReplayMessageKind | null { + switch (sessionUpdate) { + case "user_message_chunk": + return "user_message"; + case "agent_message_chunk": + return "agent_message"; + case "agent_thought_chunk": + return "agent_thought"; + default: + return null; + } +} + /** A close of the session stopped the read of its history during `session/load`. */ class SessionClosedDuringLoadError extends Error { constructor() { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 9a1f95c8d..d7977d90b 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -68,6 +68,7 @@ import type { ThreadTurnsListParams, ThreadTurnsListResponse, ThreadItem, + ThreadItemEntry, ThreadItemsListParams, ThreadItemsListResponse, Turn, @@ -96,6 +97,8 @@ import type { PermissionsRequestApprovalParams, PermissionsRequestApprovalResponse, ItemCompletedNotification, + ErrorNotification, + TurnError, } from "./app-server/v2"; import type { ThreadBackgroundTerminalsRequest, @@ -116,19 +119,19 @@ export interface ElicitationHandler { handleUserInput(params: ToolRequestUserInputParams): Promise; } -const CommandExecutionApprovalRequest = new RequestType< +export const CommandExecutionApprovalRequest = new RequestType< CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, void >('item/commandExecution/requestApproval'); -const FileChangeApprovalRequest = new RequestType< +export const FileChangeApprovalRequest = new RequestType< FileChangeRequestApprovalParams, FileChangeRequestApprovalResponse, void >('item/fileChange/requestApproval'); -const PermissionsApprovalRequest = new RequestType< +export const PermissionsApprovalRequest = new RequestType< PermissionsRequestApprovalParams, PermissionsRequestApprovalResponse, void @@ -179,6 +182,7 @@ export class CodexAppServerClient { private readonly threadGoalClearedCaptures = new Map void>>(); private readonly threadSettings = new Map(); private readonly staleTurnIds = new Map>(); + private readonly leakedReviewErrors = new Map}>(); /** Ends the notification waits below when the connection is gone, see {@link waitWhileConnected}. */ readonly connectionLoss: ConnectionLoss; @@ -189,7 +193,7 @@ export class CodexAppServerClient { this.connection.onClose?.(() => this.disposeMcpWaits()); this.connection.onDispose?.(() => this.disposeMcpWaits()); this.connection.onUnhandledNotification((data) => { - const serverNotification = data as ServerNotification; + const serverNotification = this.dropLeakedReviewError(data as ServerNotification); if (isMcpServerStatusUpdatedNotification(serverNotification)) { this.mcpStartup.record(serverNotification.params); } @@ -332,23 +336,73 @@ export class CodexAppServerClient { async runReview( params: ReviewStartParams, onTurnStarted?: (turnId: string, threadId: string) => void, + onAccepted?: () => void, ): Promise { const capturedCompletions: Array = []; const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => { capturedCompletions.push(event); }); + // If Codex cannot resolve the review request (e.g. a base branch in a non-git cwd), it reports + // one fatal error under the review turn id and never starts or completes that turn. A spawned + // review always reports `enteredReviewMode` first, so a fatal error before it ends the review. + let reviewTurnId: string | null = null; + const enteredReviewTurnIds = new Set(); + const unspawnedReviewErrors = new Map(); + const finishUnspawnedReview = (error: ErrorNotification) => { + this.leakedReviewErrors.set(error.threadId, {error: error.error, failedTurnIds: new Set()}); + this.recordTurnCompleted({ + threadId: error.threadId, + turn: { + id: error.turnId, + items: [], + itemsView: "notLoaded", + status: "failed", + error: error.error, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + }; + const releaseRoutingCapture = this.captureTurnRoutings(params.threadId, (turnId, notification) => { + if ((notification.method === "item/started" || notification.method === "item/completed") + && notification.params.item.type === "enteredReviewMode") { + enteredReviewTurnIds.add(turnId); + return; + } + if (notification.method !== "error" + || notification.params.willRetry + || enteredReviewTurnIds.has(turnId) + || unspawnedReviewErrors.has(turnId)) { + return; + } + unspawnedReviewErrors.set(turnId, notification.params); + if (turnId === reviewTurnId) { + finishUnspawnedReview(notification.params); + } + }); try { const reviewStarted = await this.reviewStart(params); + // The reviewer's own user message carries `clientId: null`, never the caller's, so + // this response is the earliest signal that Codex accepted the command. + onAccepted?.(); onTurnStarted?.(reviewStarted.turn.id, reviewStarted.reviewThreadId); const earlyCompletion = capturedCompletions.find(event => event.turn.id === reviewStarted.turn.id); releaseCapture(); if (earlyCompletion) { return earlyCompletion; } - return await this.awaitTurnCompleted(reviewStarted.reviewThreadId, reviewStarted.turn.id); + const completion = this.awaitTurnCompleted(reviewStarted.reviewThreadId, reviewStarted.turn.id); + reviewTurnId = reviewStarted.turn.id; + const earlyError = unspawnedReviewErrors.get(reviewTurnId); + if (earlyError) { + finishUnspawnedReview(earlyError); + } + return await completion; } finally { releaseCapture(); + releaseRoutingCapture(); } } @@ -357,6 +411,7 @@ export class CodexAppServerClient { onTurnStarted?: (turnId: string) => void, runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS, onGoalSet?: (goal: ThreadGoal) => void, + onAccepted?: () => void, ): Promise { let goalTurnId: string | null = null; const capturedCompletions: Array = []; @@ -408,6 +463,10 @@ export class CodexAppServerClient { const whileConnected = (wait: Promise) => this.waitWhileConnected("the goal update", wait); try { const goalSetResponse = await this.threadGoalSet(params); + // `/goal` records no user message, live or in history, so this response is the + // earliest signal that Codex accepted the command, whether or not a runtime turn + // ends up running. + onAccepted?.(); expectedGoal = goalSetResponse.goal; onGoalSet?.(expectedGoal); if (capturedGoalUpdates.some(event => goalsMatch(event.goal, expectedGoal!))) { @@ -537,6 +596,7 @@ export class CodexAppServerClient { async runCompact( params: ThreadCompactStartParams, onTurnStarted?: (turnId: string) => void, + onAccepted?: () => void, ): Promise> { type Result = CompactionCompletedNotification | Extract; let compactTurnId: string | null = null; @@ -572,6 +632,9 @@ export class CodexAppServerClient { }); try { await this.threadCompactStart(params); + // Compaction records no user message, so this is the earliest signal that Codex + // accepted the command. + onAccepted?.(); return await completed; } finally { releaseTurnCapture(); @@ -662,6 +725,16 @@ export class CodexAppServerClient { threadId: string, options: {lastItemCursor?: string | null; turnId?: string} = {}, ): AsyncGenerator { + for await (const page of this.threadItemEntryPages(threadId, options)) { + yield page.map(entry => entry.item); + } + } + + /** The pages of {@link threadItemPages}, with the turn of each item. */ + async *threadItemEntryPages( + threadId: string, + options: {lastItemCursor?: string | null; turnId?: string} = {}, + ): AsyncGenerator { const turnId = options.turnId ?? null; const last = await withHistoryTimeout(this.threadItemsList({ threadId, @@ -680,13 +753,12 @@ export class CodexAppServerClient { sortDirection: "asc", }), page => page.some(entry => entry.item.id === lastItemId)); for await (const page of pages) { - const items = page.map(entry => entry.item); - const lastIndex = items.findIndex(item => item.id === lastItemId); + const lastIndex = page.findIndex(entry => entry.item.id === lastItemId); if (lastIndex >= 0) { - yield items.slice(0, lastIndex + 1); + yield page.slice(0, lastIndex + 1); return; } - yield items; + yield page; } } @@ -924,6 +996,36 @@ export class CodexAppServerClient { } } + /** + * Codex never ends a review turn whose request failed to resolve, and then reports that turn's + * error again as the failure of the next turn on the thread, which did not fail. Report that + * next turn as completed unless it raised a fatal error of its own. + */ + private dropLeakedReviewError(notification: ServerNotification): ServerNotification { + if (notification.method === "error") { + if (!notification.params.willRetry) { + this.leakedReviewErrors.get(notification.params.threadId)?.failedTurnIds.add(notification.params.turnId); + } + return notification; + } + if (!isTurnCompletedNotification(notification)) { + return notification; + } + const {threadId, turn} = notification.params; + const leaked = this.leakedReviewErrors.get(threadId); + if (!leaked) { + return notification; + } + this.leakedReviewErrors.delete(threadId); + if (turn.status !== "failed" + || leaked.failedTurnIds.has(turn.id) + || turn.error?.message !== leaked.error.message + || JSON.stringify(turn.error.codexErrorInfo) !== JSON.stringify(leaked.error.codexErrorInfo)) { + return notification; + } + return {...notification, params: {...notification.params, turn: {...turn, status: "completed", error: null}}}; + } + private recordTurnCompleted(event: TurnCompletedNotification): void { const threadResolvers = this.pendingTurnCompletionResolvers.get(event.threadId); const resolve = threadResolvers?.get(event.turn.id); diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index c1976a00a..c7abc9556 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -1,5 +1,6 @@ import type {AuthenticateRequest, AuthMethod, ClientCapabilities} from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; export const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY"; @@ -83,6 +84,18 @@ export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | nu return authMethods; } +/** + * The same selection as {@link getCodexAuthMethods}, in the v2 descriptor shape. Every Codex + * method is an agent-handled login. Takes client capabilities already mapped to the v1 shape. + */ +export function getCodexAuthMethodsV2(clientCapabilities?: ClientCapabilities | null, env: NodeJS.ProcessEnv = process.env): acpV2.AuthMethod[] { + return getCodexAuthMethods(clientCapabilities, env).map(({id, ...method}) => ({ + methodId: id, + type: "agent" as const, + ...method, + })); +} + export type CodexAuthRequest = ApiKeyAuthRequest | ChatGPTAuthRequest | ChatGPTDeviceCodeAuthRequest | GatewayAuthRequest; export function isCodexAuthRequest(request: AuthenticateRequest): request is CodexAuthRequest { diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index afdb53ddc..673f741fe 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -23,19 +23,24 @@ type ParsedSlashCommand = { }; export type CommandHandleResult = - | { handled: false, prompt?: acp.ContentBlock[] } + | { handled: false } | { handled: true, turnCompleted?: TurnCompletedNotification }; -export const GOAL_CONTINUATION_PROMPT: acp.ContentBlock[] = [{ - type: "text", - text: "Continue working toward the active goal.", -}]; +export type PromptKind = + | { kind: "prompt" } + | { kind: "localCommand", name: string } + | { kind: "codexTurnCommand", name: string }; export type CommandHandleOptions = { /** Aborts when the prompt ends. A command then sends no more updates. */ signal?: AbortSignal; onTurnStartPending?: () => void; onTurnStarted?: (turnId: string, threadId: string) => void; + /** + * Fired once Codex accepts a command that runs a turn but records no user message + * (`/compact`, `/goal`), so a v2 caller can insert its own live-only one right away. + */ + onCommandAccepted?: () => void; setConfigOption?: (configId: string, value: string) => Promise; }; @@ -229,6 +234,42 @@ export class CodexCommands { }; } + /** + * Tells ahead of time how `tryHandleCommand` will handle a prompt: as a regular prompt, as a + * command handled locally without a Codex turn, or as a command that runs a Codex turn. + * Keep in sync with `tryHandleCommand`. + */ + classifyPrompt(prompt: acp.ContentBlock[]): PromptKind { + const command = this.parseCommand(prompt); + if (command === null || command.name.startsWith("$")) return {kind: "prompt"}; + switch (command.name) { + case "plan": + case "status": + case "rename": + case "logout": + case "skills": + case "mcp": + return {kind: "localCommand", name: command.name}; + case "compact": + case "review": + return {kind: "codexTurnCommand", name: command.name}; + case "review-branch": + case "review-commit": + return command.rest.length === 0 + ? {kind: "localCommand", name: command.name} + : {kind: "codexTurnCommand", name: command.name}; + case "goal": { + const argument = command.rest.trim().toLowerCase(); + if (argument.length === 0 || argument === "pause" || argument === "clear" || argument.length > 4000) { + return {kind: "localCommand", name: command.name}; + } + return {kind: "codexTurnCommand", name: command.name}; + } + default: + return {kind: "prompt"}; + } + } + async tryHandleCommand( prompt: acp.ContentBlock[], sessionState: SessionState, @@ -257,6 +298,7 @@ export class CodexCommands { const turnCompleted = await this.runWithProcessCheck(() => this.codexAcpClient.runCompact( sessionId, (turnId) => options.onTurnStarted?.(turnId, sessionId), + options.onCommandAccepted, )); return { handled: true, ...(turnCompleted === undefined ? {} : {turnCompleted}) }; } @@ -356,6 +398,7 @@ export class CodexCommands { (turnId, threadId) => { this.handleCommandTurnStarted(sessionState, options, turnId, threadId); }, + options.onCommandAccepted, )); } @@ -382,6 +425,8 @@ export class CodexCommands { (turnId) => { this.handleCommandTurnStarted(sessionState, options, turnId, sessionId); }, + undefined, + options.onCommandAccepted, ))); case "clear": await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionId)); @@ -401,6 +446,8 @@ export class CodexCommands { (turnId) => { this.handleCommandTurnStarted(sessionState, options, turnId, sessionId); }, + undefined, + options.onCommandAccepted, ))); } @@ -418,8 +465,11 @@ export class CodexCommands { } private createGoalCommandResult(turnCompleted: TurnCompletedNotification | null): CommandHandleResult { + // No matching turn observed within `runGoalSet`'s 1 s grace window. Codex auto-continues + // active goals itself; don't start a synthetic continuation turn here (that also skipped + // the goal-status check, so it fired even when the goal had stopped, e.g. budgetLimited). if (turnCompleted === null) { - return { handled: false, prompt: GOAL_CONTINUATION_PROMPT }; + return { handled: true }; } return { handled: true, diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 5c1e6645e..f3bac49b2 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -35,7 +35,7 @@ import type { import {toTokenCount} from "./TokenCount"; import { stripShellPrefix } from "./CommandUtils"; import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer"; -import {CommandReporter} from "./tool-calls/reporters/CommandReporter"; +import {CommandReporter, 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"; @@ -181,6 +181,30 @@ 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(); + +/** Whether a prompt failure's text has already been sent to the client as an agent message. */ +export function failureWasShownAsMessage(error: unknown): boolean { + return error instanceof RequestError && failuresShownAsMessages.has(error); +} + export class CodexEventHandler { private readonly sessionState: SessionState; @@ -495,11 +519,15 @@ export class CodexEventHandler { case "item/plan/delta": this.completeRetryIncidentOnTurnProgress(); return this.plans.delta(notification.params.itemId, notification.params.delta); - case "item/started": + case "item/started": { this.completeRetryIncidentOnTurnProgress(); - return await this.createItemEvent(notification.params); + const update = await this.createItemEvent(notification.params); + this.trackOpenToolCall(notification.params.item); + return update; + } case "item/completed": this.completeRetryIncidentOnTurnProgress(); + this.sessionState.openToolCalls.complete(notification.params.item.id); return await this.completeItemEvent(notification.params); case "turn/plan/updated": this.completeRetryIncidentOnTurnProgress(); @@ -523,14 +551,21 @@ export class CodexEventHandler { case "error": return await this.createErrorEvent(notification.params); case "turn/started": - this.sessionState.currentTurnId = notification.params.turn.id; + this.sessionState.interruptTurnId = notification.params.turn.id; + // A review's child turn starts under its own id, while the review's items, errors and + // completion keep the parent id already set from the `review/start` response. + this.sessionState.currentTurnId ??= notification.params.turn.id; await this.flushPendingErrors(); return null; case "turn/completed": await this.plans.flush(); this.plans.clearTurn(); this.sessionState.currentTurnId = null; + this.sessionState.interruptTurnId = null; this.sessionState.toolCallReports.releaseOpen(this.subagents.notificationSessionId(notification)); + // An item that missed its item/completed (e.g. dropped by a provider restart + // mid-turn) must not linger past its own turn and get failed by a later restart. + this.sessionState.openToolCalls.clear(); return null; case "thread/tokenUsage/updated": return this.createUsageUpdate(notification.params); @@ -570,7 +605,7 @@ export class CodexEventHandler { case "item/mcpToolCall/progress": this.completeRetryIncidentOnTurnProgress(); // AIR does not show MCP progress. - if (this.renderer.capabilities.airClient) return null; + if (this.renderer.capabilities.airToolCallContract) return null; return this.renderer.render(McpToolReporter.progress( notification.params.itemId, notification.params.message, @@ -782,7 +817,7 @@ export class CodexEventHandler { case "fileChange": return this.renderer.render(FileChangeReporter.started( event.item, - this.sessionState.clientCapabilities.air.diffPatch, + this.sessionState.clientCapabilities.diffPatchFormat, )); case "commandExecution": return this.renderer.render(this.commands.started(event.item)); @@ -917,6 +952,12 @@ export class CodexEventHandler { ); } + private trackOpenToolCall(item: ThreadItem): 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); + } + private async updatePlan(event: TurnPlanUpdatedNotification): Promise { const plan: PlanEntry[] = event.plan.map(value => ({ status: value.status == "inProgress" ? "in_progress" : value.status, @@ -988,6 +1029,9 @@ export class CodexEventHandler { this.createTurnErrorData(params.error), ); } + if (this.failure !== null) { + failuresShownAsMessages.add(this.failure); + } return createAgentTextMessageChunk(`${readableServiceErrorMessage(params.error.message)}\n\n`); } diff --git a/src/CodexSessionToolCalls.ts b/src/CodexSessionToolCalls.ts new file mode 100644 index 000000000..5b5be5d69 --- /dev/null +++ b/src/CodexSessionToolCalls.ts @@ -0,0 +1,45 @@ +import type {UpdateSessionEvent} from "./ACPSessionConnection"; + +/** + * Tracks tool-call items that reported `item/started` but not yet `item/completed`, for the item + * types with no outstanding-item tracker of their own (`contextCompaction` has + * `CodexSessionCompactions`; `collabAgentToolCall`/`subAgentActivity` have + * `CodexSubagentEventRouter`). Session-owned, not handler-owned, so an item started under one + * event handler and cut off before a later handler (or none) sees its completion can still be + * found and failed out. + */ +export class CodexSessionToolCalls { + private readonly open = new Map(); + + start(itemId: string, hasTerminal: boolean): void { + this.open.set(itemId, {hasTerminal}); + } + + complete(itemId: string): void { + this.open.delete(itemId); + } + + /** 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[] { + const updates: UpdateSessionEvent[] = []; + for (const [itemId, {hasTerminal}] of this.open) { + updates.push({ + sessionUpdate: "tool_call_update", + toolCallId: itemId, + status: "failed", + ...(hasTerminal ? { + _meta: { + terminal_exit: {exit_code: null, signal: null, terminal_id: itemId}, + }, + } : {}), + }); + } + this.open.clear(); + return updates; + } +} diff --git a/src/CodexThreadErrors.ts b/src/CodexThreadErrors.ts index ba7bad81e..82e667bcc 100644 --- a/src/CodexThreadErrors.ts +++ b/src/CodexThreadErrors.ts @@ -72,6 +72,16 @@ export function isNoActiveTurnError(err: unknown): boolean { return errorText(err).includes("no active turn to interrupt"); } +/** + * `turn/interrupt` answers this when Codex has since moved on to another turn -- + * most commonly a review's child turn -- registered after the id we sent. The + * message names the turn Codex considers active, which is the id to retry with. + */ +export function parseExpectedActiveTurnMismatch(err: unknown): {expected: string, found: string} | null { + const match = errorText(err).match(/expected active turn id (\S+) but found (\S+)/); + return match ? {expected: match[1]!, found: match[2]!} : null; +} + /** * True when the error means "Codex has no persisted thread under this id" for * any reason -- unparseable id, unknown id, or an id whose rollout was never diff --git a/src/GitPatch.ts b/src/GitPatch.ts index 9b598e634..8085059f9 100644 --- a/src/GitPatch.ts +++ b/src/GitPatch.ts @@ -1,8 +1,9 @@ /** - * Builds the unified Git patches of the AIR diff patch extension. + * Builds unified Git patches from the diffs that Codex reports. * + * The ACP v2 diff and the AIR diff patch extension carry these patches. * Each builder returns `null` when it cannot build a patch with at least one valid hunk. - * The caller then sends the standard ACP diff. + * For AIR, the caller then sends the standard ACP diff. */ /** The largest diff or patch text that the adapter sends. The adapter sends no diff for a larger Codex diff. */ @@ -33,6 +34,20 @@ export function createUpdateGitPatch(oldPath: string, newPath: string, diff: str return limited(`${headers.join("\n")}\n${hunks}`); } +/** Builds a patch for a moved file whose text did not change. It has no hunks. */ +export function createRenameGitPatch(oldPath: string, newPath: string): string | null { + const oldName = gitPath(oldPath); + const newName = gitPath(newPath); + if (oldName === newName) return null; + return limited([ + `diff --git ${quotedGitName("a/", oldName)} ${quotedGitName("b/", newName)}`, + "similarity index 100%", + `rename from ${quotedGitName("", oldName)}`, + `rename to ${quotedGitName("", newName)}`, + "", + ].join("\n")); +} + /** Builds a whole-file patch for an added file. */ export function createAddedFileGitPatch(filePath: string, text: string): string | null { return createWholeFilePatch(filePath, text, "added"); @@ -43,6 +58,16 @@ export function createDeletedFileGitPatch(filePath: string, text: string): strin return createWholeFilePatch(filePath, text, "deleted"); } +/** Builds a patch for an added or a deleted empty file. It has no hunks. */ +export function createEmptyFileGitPatch(filePath: string, change: "added" | "deleted"): string { + const name = gitPath(filePath); + return [ + `diff --git ${quotedGitName("a/", name)} ${quotedGitName("b/", name)}`, + `${change === "added" ? "new" : "deleted"} file mode ${REGULAR_FILE_MODE}`, + "", + ].join("\n"); +} + function createWholeFilePatch(filePath: string, text: string, change: "added" | "deleted"): string | null { // A hunk cannot express an empty file, and a binary file has no text lines. if (text.length === 0 || isBinary(text)) return null; diff --git a/src/McpServerConfig.ts b/src/McpServerConfig.ts new file mode 100644 index 000000000..b645abdcf --- /dev/null +++ b/src/McpServerConfig.ts @@ -0,0 +1,93 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import {RequestError} from "@agentclientprotocol/sdk"; +import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2"; +import type {JsonObject} from "./CodexAcpClient"; + +/** + * An MCP server entry from an ACP v1 or v2 session request. + */ +export type AcpMcpServer = acp.McpServer | acpV2.McpServer; + +/** + * Version-independent MCP server config. Optional v2 fields are already defaulted. + */ +export type NormalizedMcpServer = + | { + type: "stdio"; + name: string; + command: string; + args: string[]; + env: Array<{name: string; value: string}>; + } + | { + type: "http"; + name: string; + url: string; + headers: Array<{name: string; value: string}>; + }; + +/** + * Returns the server name. v2 custom transports have no typed `name`, so it may be empty. + */ +export function getMcpServerName(server: AcpMcpServer): string { + return typeof server.name === "string" ? server.name : ""; +} + +/** + * Converts a v1 or v2 MCP server entry into one shape, rejecting transports Codex can't run. + */ +export function normalizeMcpServer(server: AcpMcpServer): NormalizedMcpServer { + if (!("type" in server)) { + // v1 stdio entries have no `type` tag. + return {type: "stdio", name: server.name, command: server.command, args: server.args, env: server.env}; + } + switch (server.type) { + case "stdio": { + const stdio = server as acpV2.McpServerStdio; + return { + type: "stdio", + name: stdio.name, + command: stdio.command, + args: stdio.args ?? [], + env: stdio.env ?? [], + }; + } + case "http": { + const http = server as acp.McpServerHttp | acpV2.McpServerHttp; + return {type: "http", name: http.name, url: http.url, headers: http.headers ?? []}; + } + case "acp": + throw RequestError.invalidRequest("Codex doesn't support MCP ACP transport protocol"); + case "sse": + throw RequestError.invalidRequest("Codex doesn't support MCP SSE transport protocol"); + default: + // v2 allows custom and future transport types. + throw RequestError.invalidRequest(`Codex doesn't support MCP '${server.type}' transport protocol`); + } +} + +/** + * Creates a Codex `mcp_servers` config entry. + */ +export function toCodexMcpServerConfig(server: NormalizedMcpServer): JsonObject { + switch (server.type) { + case "stdio": + return { + "command": server.command, + "args": server.args, + "env": Object.fromEntries(server.env.map(env => [env.name, env.value])), + }; + case "http": + return { + "url": server.url, + "http_headers": Object.fromEntries(server.headers.map(h => [h.name, h.value])), + }; + } +} + +/** + * A session request whose `mcpServers` may come from either ACP version. + */ +export type WithAcpMcpServers = Omit & { + mcpServers?: Array; +}; diff --git a/src/SessionFork.ts b/src/SessionFork.ts index 31124af1c..bf6cadf70 100644 --- a/src/SessionFork.ts +++ b/src/SessionFork.ts @@ -7,6 +7,7 @@ import type {ModeKind} from "./app-server/ModeKind"; import type {ServiceTier} from "./app-server/ServiceTier"; import type {Model} from "./app-server/v2"; import type {SessionMetadata} from "./SessionMetadata"; +import type {AcpMcpServer, WithAcpMcpServers} from "./McpServerConfig"; export type SessionForkDependencies = { codexClient: CodexAppServerClient; @@ -14,7 +15,7 @@ export type SessionForkDependencies = { createSessionConfig( cwd: string, additionalDirectories: string[], - mcpServers: acp.McpServer[], + mcpServers: AcpMcpServer[], ): Promise; getResumeModelProvider(): Promise; fetchAvailableModels(): Promise; @@ -25,7 +26,7 @@ export type SessionForkDependencies = { }; export async function forkSession( - request: acp.ForkSessionRequest, + request: WithAcpMcpServers, additionalDirectories: string[], dependencies: SessionForkDependencies, ): Promise { @@ -68,7 +69,7 @@ export async function forkSession( } async function resolveForkTurnId( - request: acp.ForkSessionRequest, + request: Pick, codexClient: CodexAppServerClient, ): Promise { const forkPoint = readAirForkPoint(request._meta); diff --git a/src/SessionMetadata.ts b/src/SessionMetadata.ts index 8e74a7edf..04c60550a 100644 --- a/src/SessionMetadata.ts +++ b/src/SessionMetadata.ts @@ -1,6 +1,6 @@ import type {ModeKind} from "./app-server/ModeKind"; import type {ServiceTier} from "./app-server/ServiceTier"; -import type {Model, Thread, ThreadItem} from "./app-server/v2"; +import type {Model, Thread, ThreadItemEntry} from "./app-server/v2"; export type SessionMetadata = { sessionId: string, @@ -16,6 +16,6 @@ export type SessionMetadata = { export type SessionMetadataWithThread = SessionMetadata & { /** The thread metadata. Its `turns` are empty: the items are in `history`. */ thread: Thread, - /** The items of the thread, oldest first, one page at a time. */ - history: AsyncIterable, + /** The items of the thread with their turns, oldest first, one page at a time. */ + history: AsyncIterable, } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 043cc54e4..19a969c37 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -18,7 +18,7 @@ import {AgentMode} from "../../AgentMode"; import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; -import {GOAL_CONTROL_METHOD} from "../../AcpExtensions"; +import {GOAL_CONTROL_METHOD, SESSION_STEERING_METHOD} from "../../AcpExtensions"; import {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; import type {McpStartupResult} from "../../mcp/McpStartupTracker"; @@ -3218,12 +3218,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { action: "clear", })).resolves.toEqual({}); - expect(setGoalSpy).toHaveBeenCalledWith("session-id", "Replace the objective", undefined, expect.any(Function)); + expect(setGoalSpy).toHaveBeenCalledWith("session-id", "Replace the objective"); expect(setStatusSpy).toHaveBeenNthCalledWith(1, "session-id", "paused"); expect(resumeGoalSpy).toHaveBeenCalledWith("session-id", undefined, expect.any(Function)); expect(clearGoalSpy).toHaveBeenCalledWith("session-id"); - expect(getGoalSpy).toHaveBeenCalledTimes(2); - expect(turnStartSpy).toHaveBeenCalledTimes(2); + expect(getGoalSpy).not.toHaveBeenCalled(); + expect(turnStartSpy).not.toHaveBeenCalled(); const goalUpdates = mockFixture.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate" && "args" in event @@ -3254,25 +3254,16 @@ describe('ACP server test', { timeout: 40_000 }, () => { ])); }); - it('waits for an active turn before starting goal work', async () => { + it('does not start a continuation turn when goal set routes no app-server turn', async () => { const {mockFixture, sessionState, turnStartSpy} = setupPromptFixture(); // @ts-expect-error - registering local session state for the extension request path mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); const activeTurnCompleted = deferred(); vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") .mockReset() - .mockReturnValueOnce(activeTurnCompleted.promise) - .mockResolvedValue({ - threadId: "session-id", - turn: createTurn("goal-work-turn", "completed"), - }); + .mockReturnValueOnce(activeTurnCompleted.promise); const goal = createThreadGoal({objective: "Finish the migration", status: "active"}); - vi.spyOn(mockFixture.getCodexAcpClient(), "setGoal") - .mockImplementation(async (_sessionId, _objective, _onTurnStarted, onGoalSet) => { - onGoalSet?.(goal); - return null; - }); - vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal").mockResolvedValue(goal); + const setGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "setGoal").mockResolvedValue(null); const activePrompt = mockFixture.getCodexAcpAgent().prompt({ sessionId: "session-id", @@ -3280,12 +3271,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); await vi.waitFor(() => expect(turnStartSpy).toHaveBeenCalledTimes(1)); - const setGoal = mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + // Codex 0.156.1 continues an active goal on its own; codex-acp must not race it with a + // synthetic "Continue working toward the active goal." turn of its own. + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { sessionId: "session-id", action: "set", objective: goal.objective, - }); - await flushAsyncWork(); + })).resolves.toEqual({}); + expect(setGoalSpy).toHaveBeenCalledWith("session-id", goal.objective); expect(turnStartSpy).toHaveBeenCalledTimes(1); activeTurnCompleted.resolve({ @@ -3293,37 +3286,9 @@ describe('ACP server test', { timeout: 40_000 }, () => { turn: createTurn("turn-id", "completed"), }); await expect(activePrompt).resolves.toMatchObject({stopReason: "end_turn"}); - await expect(setGoal).resolves.toEqual({}); - expect(turnStartSpy).toHaveBeenCalledTimes(2); - expect(turnStartSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - input: [expect.objectContaining({text: "Continue working toward the active goal."})], - })); }); - it('starts goal work when resume routes no app-server turn', async () => { - const {mockFixture, sessionState, turnStartSpy} = setupPromptFixture(); - // @ts-expect-error - registering local session state for the extension request path - mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); - const goal = createThreadGoal({objective: "Finish the migration", status: "active"}); - vi.spyOn(mockFixture.getCodexAcpClient(), "resumeGoal") - .mockImplementation(async (_sessionId, _onTurnStarted, onGoalSet) => { - onGoalSet?.(goal); - return null; - }); - vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal").mockResolvedValue(goal); - - await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { - sessionId: "session-id", - action: "resume", - })).resolves.toEqual({}); - - expect(turnStartSpy).toHaveBeenCalledTimes(1); - expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ - input: [expect.objectContaining({text: "Continue working toward the active goal."})], - })); - }); - - it('starts only the latest goal replacement after an active turn', async () => { + it('serializes a steering turn-start behind an in-flight v2 prompt', async () => { const {mockFixture, sessionState, turnStartSpy} = setupPromptFixture(); // @ts-expect-error - registering local session state for the extension request path mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); @@ -3333,45 +3298,76 @@ describe('ACP server test', { timeout: 40_000 }, () => { .mockReturnValueOnce(activeTurnCompleted.promise) .mockResolvedValue({ threadId: "session-id", - turn: createTurn("goal-work-turn", "completed"), + turn: createTurn("steered-turn", "completed"), }); - const firstGoal = createThreadGoal({objective: "First replacement", createdAt: 1}); - const latestGoal = createThreadGoal({objective: "Latest replacement", createdAt: 2}); - vi.spyOn(mockFixture.getCodexAcpClient(), "setGoal") - .mockImplementation(async (_sessionId, objective, _onTurnStarted, onGoalSet) => { - onGoalSet?.(objective === firstGoal.objective ? firstGoal : latestGoal); - return null; - }); - const getGoal = vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal").mockResolvedValue(latestGoal); + // Codex reports no active turn to steer into, so the steer falls back to starting a + // fresh turn instead of injecting into the still-running one. + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer").mockRejectedValue( + Object.assign(new Error("Internal error"), {data: {details: "no active turn to steer"}}), + ); - const activePrompt = mockFixture.getCodexAcpAgent().prompt({ + // The active work is a v2 prompt this time, not v1 `prompt()`, to confirm both go through + // the same shared turn-start reservation. + const activePrompt = mockFixture.getCodexAcpAgent().promptV2({ sessionId: "session-id", prompt: [{type: "text", text: "Work already in progress"}], }); await vi.waitFor(() => expect(turnStartSpy).toHaveBeenCalledTimes(1)); - const firstSet = mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { - sessionId: "session-id", - action: "set", - objective: firstGoal.objective, + const clientUserMessageId = (turnStartSpy.mock.calls[0]![0] as {clientUserMessageId: string}).clientUserMessageId; + mockFixture.sendServerNotification({ + method: "item/completed", + params: { + threadId: "session-id", + turnId: "turn-id", + item: { + type: "userMessage", + id: "item-user", + clientId: clientUserMessageId, + content: [{type: "text", text: "Work already in progress", text_elements: []}], + }, + completedAtMs: 0, + }, }); - const latestSet = mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + await expect(activePrompt).resolves.toEqual({messageId: clientUserMessageId}); + + const steer = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { sessionId: "session-id", - action: "set", - objective: latestGoal.objective, + prompt: [{type: "text", text: "Steer while busy"}], }); await flushAsyncWork(); + // The v2 prompt already answered its request, but its turn is still running: the + // steering starter waits on the shared reservation instead of racing it. expect(turnStartSpy).toHaveBeenCalledTimes(1); activeTurnCompleted.resolve({ threadId: "session-id", turn: createTurn("turn-id", "completed"), }); - await expect(activePrompt).resolves.toMatchObject({stopReason: "end_turn"}); - await expect(firstSet).resolves.toEqual({}); - await expect(latestSet).resolves.toEqual({}); - + await expect(steer).resolves.toEqual({outcome: "startedNewTurn"}); expect(turnStartSpy).toHaveBeenCalledTimes(2); - expect(getGoal).toHaveBeenCalledTimes(1); + expect(turnStartSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + input: [expect.objectContaining({text: "Steer while busy"})], + })); + }); + + it('does not start a continuation turn when resume routes no app-server turn', async () => { + const {mockFixture, sessionState, turnStartSpy} = setupPromptFixture(); + // @ts-expect-error - registering local session state for the extension request path + mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); + const goal = createThreadGoal({objective: "Finish the migration", status: "active"}); + const resumeGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "resumeGoal") + .mockImplementation(async (_sessionId, _onTurnStarted, onGoalSet) => { + onGoalSet?.(goal); + return null; + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + sessionId: "session-id", + action: "resume", + })).resolves.toEqual({}); + + expect(resumeGoalSpy).toHaveBeenCalledWith("session-id", undefined, expect.any(Function)); + expect(turnStartSpy).not.toHaveBeenCalled(); }); it('does not start queued goal work after the goal is paused', async () => { @@ -3642,7 +3638,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { } }); - it('starts a goal work turn when app server starts no continuation turn', async () => { + it('completes the /goal command without a continuation turn when app server starts no turn', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture(); const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet") .mockResolvedValue(null); @@ -3658,13 +3654,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { objective: "Ship the migration and keep tests green", status: "active", }, expect.any(Function)); - expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ - input: [{ - type: "text", - text: "Continue working toward the active goal.", - text_elements: [], - }], - })); + expect(turnStartSpy).not.toHaveBeenCalled(); }); it('reports missing goal slash command input', async () => { diff --git a/src/__tests__/CodexACPAgent/agent-initiated-turns-v1.test.ts b/src/__tests__/CodexACPAgent/agent-initiated-turns-v1.test.ts new file mode 100644 index 000000000..6b2758d30 --- /dev/null +++ b/src/__tests__/CodexACPAgent/agent-initiated-turns-v1.test.ts @@ -0,0 +1,69 @@ +import {afterEach, describe, expect, it} from 'vitest'; +import { + sessionId, + cwd, + connectSession, + turnStarted, + turnCompleted, + agentMessageDelta, + settle, + type PromptSession, +} from './v2-prompt-harness'; + +function agentMessageChunks(transcript: PromptSession['transcript']) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "agent_message_chunk"); +} + +/** + * On ACP v1, the baseline Codex-turn tracker (installed for every session, before any + * `session/prompt` has run) now renders items for a Codex-initiated turn, reusing the same + * `CodexEventHandler` rendering `session/prompt` uses. Previously it only tracked turn state and + * rendered nothing, so a Codex-started turn (e.g. a `/goal` auto-continuation right after + * `session/new`/`session/resume`) was invisible on v1. + */ +describe('agent-initiated Codex turns over ACP v1', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + }); + + it('renders items for a Codex turn that starts right after session/new, before any prompt has run', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + const start = client.transcript.length; + + client.emit(turnStarted()); + await settle(); + client.emit(agentMessageDelta("Working on the goal")); + await settle(); + client.emit(turnCompleted()); + await settle(); + + const transcript = client.transcript.slice(start); + expect(agentMessageChunks(transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('renders items for a Codex turn that starts right after session/resume, before any prompt has run', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + await client.request("session/resume", {sessionId, cwd, mcpServers: []}); + const start = client.transcript.length; + + client.emit(turnStarted()); + await settle(); + client.emit(agentMessageDelta("Working on the goal")); + await settle(); + client.emit(turnCompleted()); + await settle(); + + const transcript = client.transcript.slice(start); + expect(agentMessageChunks(transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/agent-initiated-turns-v2.test.ts b/src/__tests__/CodexACPAgent/agent-initiated-turns-v2.test.ts new file mode 100644 index 000000000..2c6be0126 --- /dev/null +++ b/src/__tests__/CodexACPAgent/agent-initiated-turns-v2.test.ts @@ -0,0 +1,200 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {CommandExecutionApprovalRequest} from '../../CodexAppServerClient'; +import type {CommandExecutionRequestApprovalParams} from '../../app-server/v2'; +import { + sessionId, + turnId, + cwd, + connectSession, + userMessageItem, + itemCompleted, + turnStarted, + turnCompleted, + agentMessageDelta, + settle, + stateUpdates, + type PromptSession, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +function userMessageChunks(transcript: PromptSession['transcript']) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "user_message_chunk"); +} + +function agentMessageChunks(transcript: PromptSession['transcript']) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "agent_message_chunk"); +} + +function commandApprovalParams(overrides: Partial = {}): CommandExecutionRequestApprovalParams { + return { + kind: "command", + threadId: sessionId, + turnId, + itemId: "cmd-item-1", + startedAtMs: 0, + environmentId: null, + command: "ls -la", + cwd, + ...overrides, + }; +} + +/** Runs a normal prompt to completion, so `v2PromptsInFlight` no longer owns the session. */ +async function runPromptToCompletion(client: PromptSession) { + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); +} + +describe('agent-initiated Codex turns over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('sends running/idle and renders items for a Codex turn that starts right after session/new, before any prompt has run', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const start = client.transcript.length; + + client.emit(turnStarted()); + await settle(); + client.emit(agentMessageDelta("Working on the goal")); + await settle(); + client.emit(turnCompleted()); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + expect(userMessageChunks(transcript)).toEqual([]); + expect(agentMessageChunks(transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('sends running/idle and renders items for a Codex turn that starts right after session/resume, before any prompt has run', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + await client.request("session/resume", {sessionId, cwd}); + const start = client.transcript.length; + + client.emit(turnStarted()); + await settle(); + client.emit(agentMessageDelta("Working on the goal")); + await settle(); + client.emit(turnCompleted()); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + expect(userMessageChunks(transcript)).toEqual([]); + expect(agentMessageChunks(transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('does not double-render items for a turn a session/prompt owns', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; + client.emit(agentMessageDelta("Hi there")); + await settle(); + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('sends running/idle, without a user_message, for a Codex turn that continues after a prompt\'s own idle', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + await runPromptToCompletion(client); + // The prompt owned its own turn: exactly one running/idle pair, no duplicates. + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + + const start = client.transcript.length; + // No `session/prompt` started this one (e.g. a `/goal` auto-continuation): the same + // subscription that served the prompt above keeps receiving it. + client.emit(turnStarted("turn-2")); + await settle(); + client.emit(turnCompleted("turn-2")); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + expect(userMessageChunks(transcript)).toEqual([]); + }); + + it('reports `cancelled` for an unowned turn Codex reports as interrupted', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + await runPromptToCompletion(client); + + const start = client.transcript.length; + client.emit(turnStarted("turn-2")); + await settle(); + client.emit({method: "turn/completed", params: {threadId: sessionId, turn: { + id: "turn-2", items: [], itemsView: "notLoaded", status: "interrupted", + error: null, startedAt: null, completedAt: null, durationMs: null, + }}}); + await settle(); + + expect(stateUpdates(client.transcript.slice(start))).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + ]); + }); + + it('resends `running` for a permission request that settles while an unowned turn is still running', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "selected", optionId: "allow_once"}}), + }); + closeClient = () => client.connection.close(); + await runPromptToCompletion(client); + + const start = client.transcript.length; + // A goal continuation (unowned) that is still running when the permission settles. + client.emit(turnStarted("turn-2")); + await settle(); + + const response = await client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams({turnId: "turn-2"})); + expect(response).toEqual({decision: "accept"}); + + client.emit(turnCompleted("turn-2")); + await settle(); + + const transcript = client.transcript.slice(start); + // `running` from the tracked turn start, then `requires_action`/`running` bracketing the + // permission request (a turn is genuinely running, so `running` is not suppressed here -- + // unlike the no-turn-running case covered in permissions-v2.test.ts), then `idle`. + expect(stateUpdates(transcript)).toEqual([ + {state: "running"}, + {state: "requires_action"}, + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + expect(userMessageChunks(transcript)).toEqual([]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index b3e9c041b..d2ebc49a3 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -20,6 +20,14 @@ type CommandParams = CommandExecutionRequestApprovalParams & { availableDecisions?: unknown; }; +function deferred(): {promise: Promise; resolve: (value: T) => void} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} + describe("Approval Events", () => { let fixture: CodexMockTestFixture; const sessionId = "test-session-id"; @@ -619,6 +627,66 @@ describe("Approval Events", () => { expect(JSON.stringify(requests)).not.toContain("approval-b"); await finish(prompt); }); + + it("aborts a pending approval's cancellation signal on session/cancel and ends the prompt cancelled", async () => { + let resolveTurnCompleted!: (value: { + threadId: string; + turn: {id: string; items: never[]; status: string; error: null}; + }) => void; + const turnCompletedPromise = new Promise<{ + threadId: string; + turn: {id: string; items: never[]; status: string; error: null}; + }>(resolve => { + resolveTurnCompleted = resolve; + }); + fixture.getCodexAppServerClient().turnStart = vi.fn().mockResolvedValue({ + turn: {id: "turn-1", items: [], status: "inProgress", error: null}, + }); + fixture.getCodexAppServerClient().awaitTurnCompleted = vi.fn().mockReturnValue(turnCompletedPromise); + const sessionState: SessionState = createTestSessionState({ + sessionId, + currentModelId: "model-id[effort]", + agentMode: AgentMode.DEFAULT_AGENT_MODE, + }); + vi.spyOn(fixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + // `cancel()` looks the session up in the live `sessions` map, not through the + // `getSessionState` mock other tests in this file rely on. + // @ts-expect-error - registering local session state for the cancel path + fixture.getCodexAcpAgent().sessions.set(sessionId, sessionState); + const promptPromise = fixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{type: "text", text: "Test prompt"}], + }); + + const turnInterrupt = vi.spyOn(fixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async () => { + resolveTurnCompleted({ + threadId: sessionId, + turn: {id: "turn-1", items: [], status: "interrupted", error: null}, + }); + }); + const permission = deferred<{outcome: {outcome: string; optionId?: string}}>(); + fixture.setPermissionResponse(permission.promise as any); + + const approvalPromise = fixture.sendServerRequest<{decision: unknown}>( + "item/commandExecution/requestApproval", + commandParams(["accept", "decline", "cancel"]), + ); + await vi.waitFor(() => expect(permissionRequest()).toBeDefined()); + await vi.waitFor(() => expect(sessionState.currentTurnId).toBe("turn-1")); + + await fixture.getCodexAcpAgent().cancel({sessionId}); + + expect(turnInterrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "turn-1"}); + const requestOptions = fixture.getAcpRequestOptions("session/request_permission"); + expect(requestOptions.length).toBeGreaterThan(0); + expect(requestOptions[0]?.cancellationSignal?.aborted).toBe(true); + + // A conforming client answers `cancelled` once it has seen `session/cancel`, per the ACP spec. + permission.resolve({outcome: {outcome: "cancelled"}}); + expect(await approvalPromise).toEqual({decision: "cancel"}); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + }); }); describe("file change approvals", () => { diff --git a/src/__tests__/CodexACPAgent/async-task-stop-v2.test.ts b/src/__tests__/CodexACPAgent/async-task-stop-v2.test.ts new file mode 100644 index 000000000..e546b01ab --- /dev/null +++ b/src/__tests__/CodexACPAgent/async-task-stop-v2.test.ts @@ -0,0 +1,134 @@ +import {afterEach, describe, expect, it} from 'vitest'; +import type {ThreadItem} from '../../app-server/v2'; +import type {ServerNotification} from '../../app-server'; +import {ASYNC_TASK_STOP_METHOD} from '../../async-tasks/AsyncTaskExtension'; +import {AIR_ASYNC_TASKS_KEY} from '../../AirExtension'; +import { + sessionId, + connectSession, + itemStarted, + turnStarted, + settle, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +type CommandExecutionItem = Extract; + +function commandItem(overrides: Partial = {}): CommandExecutionItem { + return { + type: "commandExecution", + id: "command-1", + pluginId: null, + scriptPath: null, + command: "python -m http.server", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + ...overrides, + }; +} + +const reasoningItem: ThreadItem = {type: "reasoning", id: "reasoning-1", summary: [], content: []}; + +const asyncTasksClientCapabilities = { + _meta: {jetbrains: {air: {version: 1, capabilities: [AIR_ASYNC_TASKS_KEY]}}}, +}; + +/** Emits the notifications that make `CodexBackgroundTerminalTasks` announce and sync a spawned task. */ +async function spawnAsyncTask(client: {emit: (n: ServerNotification) => void}) { + client.emit(turnStarted()); + await settle(); + client.emit(itemStarted(commandItem())); + await settle(); + // A second `item/started` triggers the terminal-list sync that publishes the spawn. + client.emit(itemStarted(reasoningItem)); + await settle(); +} + +describe('_session/async_task/stop on ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + expectConformingV2SessionUpdates(); + }); + + it('stops a spawned async task on v2, returning {stopped: true} and rendering _async_task_state_update', async () => { + const client = await connectSession(2, {clientCapabilities: asyncTasksClientCapabilities}); + closeClient = () => client.connection.close(); + client.setCodexResponse("thread/backgroundTerminals/list", async () => ({ + data: [{itemId: "command-1", processId: "42", command: "python -m http.server"}], + nextCursor: null, + })); + client.setCodexResponse("thread/backgroundTerminals/terminate", async () => ({terminated: true})); + + await spawnAsyncTask(client); + + const spawned = client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "_async_task_spawned"); + expect(spawned).toEqual([expect.objectContaining({asyncTaskId: "command-1"})]); + + const stopRequest = client.request(ASYNC_TASK_STOP_METHOD, {sessionId, asyncTaskId: "command-1"}); + await expect(stopRequest).resolves.toEqual({stopped: true}); + await settle(); + + const stateUpdates = client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "_async_task_state_update"); + expect(stateUpdates).toEqual([{sessionUpdate: "_async_task_state_update", asyncTaskId: "command-1", state: "stopped", toolCallId: "command-1"}]); + }); + + // v1 renders the same lifecycle as bare `async_task_spawned`/`async_task_state_update` tags + // (pre-`_`-prefix convention), which the real v1 SDK client schema does not model -- see + // `async-tasks.test.ts` for the v1 rendering pin, using a raw mock connection instead of the + // real wire-validating client. This file's v1 iterations below pin the *router-level* + // extension-method behavior (params, unknown-session/task handling, errors), which is + // unaffected by that. + + it('stopping an unknown task id returns {stopped: false} on both v1 and v2, matching an unknown session', async () => { + for (const protocolVersion of [1, 2] as const) { + const client = await connectSession(protocolVersion); + closeClient = () => client.connection.close(); + + await expect(client.request(ASYNC_TASK_STOP_METHOD, { + sessionId, asyncTaskId: "no-such-task", + })).resolves.toEqual({stopped: false}); + await expect(client.request(ASYNC_TASK_STOP_METHOD, { + sessionId: "no-such-session", asyncTaskId: "no-such-task", + })).resolves.toEqual({stopped: false}); + client.connection.close(); + closeClient = null; + } + }); + + it('rejects a blank sessionId with invalid_params on both v1 and v2', async () => { + for (const protocolVersion of [1, 2] as const) { + const client = await connectSession(protocolVersion); + closeClient = () => client.connection.close(); + + await expect(client.request(ASYNC_TASK_STOP_METHOD, { + sessionId: " ", asyncTaskId: "command-1", + })).rejects.toMatchObject({code: -32602}); + client.connection.close(); + closeClient = null; + } + }); + + it('rejects a blank asyncTaskId with invalid_params on both v1 and v2', async () => { + for (const protocolVersion of [1, 2] as const) { + const client = await connectSession(protocolVersion); + closeClient = () => client.connection.close(); + + await expect(client.request(ASYNC_TASK_STOP_METHOD, { + sessionId, asyncTaskId: " ", + })).rejects.toMatchObject({code: -32602}); + client.connection.close(); + closeClient = null; + } + }); +}); diff --git a/src/__tests__/CodexACPAgent/auth-v2.test.ts b/src/__tests__/CodexACPAgent/auth-v2.test.ts new file mode 100644 index 000000000..4409d1296 --- /dev/null +++ b/src/__tests__/CodexACPAgent/auth-v2.test.ts @@ -0,0 +1,206 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import type {Thread} from '../../app-server/v2'; +import {createTestModel} from '../acp-test-utils'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +const sessionId = "thread-1"; +const cwd = "/workspace"; + +function createThread(): Thread { + return { + id: sessionId, + sessionId, + parentThreadId: null, + threadSource: null, + originator: null, + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + model: null, + reasoningEffort: null, + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd, + cliVersion: "0.0.0", + section: null, + sectionEnteredAt: null, + projectId: null, + historyMode: "legacy", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; +} + +/** Methods whose requests the snapshots record; the rest is session-setup noise. */ +const recordedCodexMethods = new Set([ + "account/login/start", + "account/logout", + "thread/start", +]); + +/** + * Connects a v2 client to the agent through the router, over a mocked Codex app-server + * whose account starts logged out. An API key login succeeds unless `loginSucceeds` is false. + */ +async function connectV2Client(options: {loginSucceeds?: boolean} = {}) { + const loginSucceeds = options.loginSucceeds ?? true; + const mocks = createMockConnections(); + const codexRequests: Array<{method: string, params: unknown}> = []; + let loggedIn = false; + // Codex answers a login or logout request first, then pushes the outcome. + const pushNotification = (method: string, params: unknown) => setImmediate(() => { + mocks.notificationHandlers.get(method)?.(params); + }); + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string, params?: unknown) => { + if (recordedCodexMethods.has(method)) { + codexRequests.push({method, params}); + } + switch (method) { + case "account/read": + return { + account: loggedIn ? {type: "apiKey"} : null, + requiresOpenaiAuth: true, + }; + case "account/login/start": + loggedIn = loginSucceeds; + pushNotification("account/login/completed", { + loginId: null, + success: loginSucceeds, + error: loginSucceeds ? null : "invalid key", + onboardingEntrypoint: null, + }); + return {type: "apiKey"}; + case "account/logout": + loggedIn = false; + pushNotification("account/updated", {authMode: null, planType: null}); + return {}; + case "thread/start": + return { + thread: createThread(), + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + turnsBackwardsCursor: null, + }; + case "model/list": + return {data: [createTestModel({id: "gpt-5"})], nextCursor: null}; + case "skills/list": + return {data: []}; + case "config/read": + return {config: {}, origins: {}, layers: []}; + case "thread/goal/get": + return {goal: null}; + default: + return {}; + } + }); + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockResolvedValue({ready: [], failed: [], cancelled: []}); + const router = createAcpAgentRouter((connection) => new CodexAcpServer(connection, codexAcpClient)); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => checkV2SessionUpdate(ctx.params.update)) + .connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable)); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + }); + return {connection, codexRequests}; +} + +function toError(err: {code: number, message: string, data?: unknown}) { + return {code: err.code, message: err.message, data: err.data}; +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +const apiKeyLogin: acpV2.LoginAuthRequest = { + methodId: "api-key", + _meta: {"api-key": {apiKey: "sk-test"}}, +}; + +describe('Auth over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('requires auth for session/new, then creates the session after auth/login', async () => { + const {connection, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + + const authRequired = await connection.agent.request(acpV2.methods.agent.session.new, {cwd}) + .then(() => null, toError); + const login = await connection.agent.request(acpV2.methods.agent.auth.login, apiKeyLogin); + const session = await connection.agent.request(acpV2.methods.agent.session.new, {cwd}); + + expect(authRequired?.code).toBe(-32000); + expect(session.sessionId).toBe(sessionId); + await expect(dump({authRequired, login, codexRequests})) + .toMatchFileSnapshot('data/auth-v2-login-then-new-session.json'); + }); + + it('rejects auth/login when the Codex login fails', async () => { + const {connection, codexRequests} = await connectV2Client({loginSucceeds: false}); + closeClient = () => connection.close(); + + const error = await connection.agent.request(acpV2.methods.agent.auth.login, apiKeyLogin) + .then(() => null, toError); + + await expect(dump({error, codexRequests})).toMatchFileSnapshot('data/auth-v2-login-failed.json'); + }); + + it('logs out with auth/logout', async () => { + const {connection, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + await connection.agent.request(acpV2.methods.agent.auth.login, apiKeyLogin); + codexRequests.splice(0); + + const logout = await connection.agent.request(acpV2.methods.agent.auth.logout, {}); + const authRequired = await connection.agent.request(acpV2.methods.agent.session.new, {cwd}) + .then(() => null, toError); + + expect(authRequired?.code).toBe(-32000); + await expect(dump({logout, codexRequests})).toMatchFileSnapshot('data/auth-v2-logout.json'); + }); + + it('does not answer the v1 auth method names', async () => { + const {connection, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + + const errors: Record = {}; + for (const method of ["authenticate", "logout", "authentication/status", "authentication/logout"]) { + errors[method] = await connection.agent.request(method as `_${string}`, {methodId: "api-key"}) + .then(() => null, toError); + } + + expect(codexRequests).toEqual([]); + await expect(dump(errors)).toMatchFileSnapshot('data/auth-v2-v1-method-names.json'); + }); +}); diff --git a/src/__tests__/CodexACPAgent/cancel-turn-registration-race.test.ts b/src/__tests__/CodexACPAgent/cancel-turn-registration-race.test.ts index 0bcfd0d97..e958d1f72 100644 --- a/src/__tests__/CodexACPAgent/cancel-turn-registration-race.test.ts +++ b/src/__tests__/CodexACPAgent/cancel-turn-registration-race.test.ts @@ -49,16 +49,23 @@ describe("session/cancel racing Codex's turn registration", () => { await turn.finish(); }); - it("does not retry on close, which tears the session down anyway", async () => { + it("retries a close interrupt the same way as a cancel", async () => { const turn = await startPrompt(); const turnInterrupt = vi.spyOn(turn.codexAcpClient, "turnInterrupt") - .mockRejectedValue(NO_ACTIVE_TURN); + .mockRejectedValueOnce(NO_ACTIVE_TURN) + .mockRejectedValueOnce(NO_ACTIVE_TURN) + .mockResolvedValueOnce(undefined); const closed = turn.codexAcpAgent.closeSession({sessionId}); + // The turn is still active while the retries run: only resolve it once + // `turnInterrupt` has gone through its retry budget, otherwise the underlying + // prompt would complete and drop the session from the active-prompts set first. + await vi.waitFor(() => expect(turnInterrupt).toHaveBeenCalledTimes(3)); await turn.finish(); await closed; - expect(turnInterrupt).toHaveBeenCalledTimes(1); + expect(turnInterrupt).toHaveBeenCalledTimes(3); + expect(turnInterrupt).toHaveBeenLastCalledWith({threadId: sessionId, turnId}); }); }); diff --git a/src/__tests__/CodexACPAgent/cancel-v2.test.ts b/src/__tests__/CodexACPAgent/cancel-v2.test.ts new file mode 100644 index 000000000..82fd5004b --- /dev/null +++ b/src/__tests__/CodexACPAgent/cancel-v2.test.ts @@ -0,0 +1,416 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {CommandExecutionApprovalRequest} from '../../CodexAppServerClient'; +import type {CommandExecutionRequestApprovalParams} from '../../app-server/v2'; +import { + connectSession, + createTurn, + userMessageItem, + itemCompleted, + turnStarted, + turnCompleted, + turnFinished, + settle, + stateUpdates, + dump, + sessionId, + turnId, + cwd, + type PromptSession, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +function deferred(): {promise: Promise; resolve: (value: T) => void} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} + +function commandApprovalParams(): CommandExecutionRequestApprovalParams { + return { + kind: "command", + threadId: sessionId, + turnId, + itemId: "cmd-item-1", + startedAtMs: 0, + environmentId: null, + command: "ls -la", + cwd, + }; +} + +/** Starts a prompt and lets its turn actually start, without inserting its user message yet. */ +async function startRunningPrompt(client: PromptSession) { + client.setTurnStart(async () => ({ + turn: createTurn("inProgress"), + })); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + return {response, clientUserMessageId}; +} + +describe('session/cancel and session/close over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('cancels a running prompt with exactly one idle/cancelled', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await response; + + await client.cancel(); + client.emit(turnFinished("interrupted")); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "cancelled"}]); + await expect(dump(client.transcript, client.turnStartParams[0]!["clientUserMessageId"] as string)) + .toMatchFileSnapshot('data/cancel-v2-running-prompt.json'); + }); + + it('drops queued prompts with -32800 on cancel, and lets a later prompt run normally', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const first = client.sendPrompt([{type: "text", text: "One"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await first; + + // B and C queue behind the running turn: nothing about them is observable yet. + const second = client.sendPrompt([{type: "text", text: "Two"}]); + const third = client.sendPrompt([{type: "text", text: "Three"}]); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + + await client.cancel(); + // B and C are rejected immediately -- they never wait for A's interrupt to land. + await expect(second).rejects.toMatchObject({code: -32800}); + await expect(third).rejects.toMatchObject({code: -32800}); + client.emit(turnFinished("interrupted")); + await client.promptRunFinished(); + await settle(); + + // Neither B ("Two") nor C ("Three") ever reached `turn/start` or sent a user message. + expect(client.turnStartParams).toHaveLength(1); + const userMessageTexts = client.transcript.flatMap(entry => { + if (!("sessionUpdate" in entry) || entry.sessionUpdate.sessionUpdate !== "user_message_chunk") { + return []; + } + const content = entry.sessionUpdate.content as {type: string, text?: string}; + return content.type === "text" ? [content.text] : []; + }); + expect(userMessageTexts).not.toEqual(expect.arrayContaining(["Two", "Three"])); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "cancelled"}]); + + // The queue is empty afterwards: a fresh prompt runs like normal, not as if still queued. + const fourth = client.sendPrompt([{type: "text", text: "Four"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[1]!["clientUserMessageId"] as string, "Four"), "turn-2")); + const {messageId} = await fourth; + expect(messageId).toBe(client.turnStartParams[1]!["clientUserMessageId"]); + client.emit({method: "turn/completed", params: {threadId: "thread-1", turn: createTurn("completed", "turn-2")}}); + await client.promptRunFinished(1); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, {state: "idle", stopReason: "cancelled"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('drops a queued prompt with -32800 on session/close', async () => { + const client = await connectSession(); + closeClient = null; // closeSession below already tears the session down. + + const first = client.sendPrompt([{type: "text", text: "One"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await first; + + const second = client.sendPrompt([{type: "text", text: "Two"}]); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + + const closed = client.request("session/close", {sessionId: "thread-1"}); + await expect(second).rejects.toMatchObject({code: -32800}); + client.emit(turnFinished("interrupted")); + await client.promptRunFinished(); + await closed; + + expect(client.turnStartParams).toHaveLength(1); + client.connection.close(); + }); + + it('fails an adopted-but-not-landed prompt with -32800 when its turn is cancelled', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + // An unowned turn (e.g. a `/goal` auto-continuation) is already running. + client.emit(turnStarted("goal-turn")); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + const start = client.transcript.length; + client.setTurnStart(async () => ({turn: createTurn("inProgress", "goal-turn")})); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + + await client.cancel(); + // Codex drops the steered input: the adopted turn is interrupted before it lands. + client.emit(turnFinished("interrupted", "goal-turn")); + await client.promptRunFinished(); + await settle(); + + await expect(response).rejects.toMatchObject({code: -32800}); + const transcript = client.transcript.slice(start); + expect(transcript.some(entry => "sessionUpdate" in entry && entry.sessionUpdate.sessionUpdate === "user_message_chunk")) + .toBe(false); + // Exactly one `idle` for the adopted turn: nothing else will send it, since this prompt + // is the one that claimed ownership (and suppressed the baseline unowned-turn tracker). + expect(stateUpdates(transcript)).toEqual([{state: "idle", stopReason: "cancelled"}]); + }); + + it('drops one queued prompt via `$/cancel_request`, leaving the other to run normally', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const first = client.sendPrompt([{type: "text", text: "One"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await first; + + // B and C both queue behind the running turn. + const controllerB = new AbortController(); + const second = client.sendPrompt([{type: "text", text: "Two"}], controllerB.signal); + const third = client.sendPrompt([{type: "text", text: "Three"}]); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + + // Only B's request is cancelled: C is untouched, and the running turn keeps going. + controllerB.abort(); + await expect(second).rejects.toMatchObject({code: -32800}); + await settle(); + expect(client.turnInterruptCalls()).toEqual([]); + + client.emit(turnFinished("completed")); + await client.promptRunFinished(); + await settle(); + + // C still runs once A finishes: dropping B did not disturb its FIFO slot. + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[1]!["clientUserMessageId"] as string, "Three"), "turn-2")); + const {messageId} = await third; + expect(messageId).toBe(client.turnStartParams[1]!["clientUserMessageId"]); + client.emit({method: "turn/completed", params: {threadId: "thread-1", turn: createTurn("completed", "turn-2")}}); + await client.promptRunFinished(1); + await settle(); + + const userMessageTexts = client.transcript.flatMap(entry => { + if (!("sessionUpdate" in entry) || entry.sessionUpdate.sessionUpdate !== "user_message_chunk") { + return []; + } + const content = entry.sessionUpdate.content as {type: string, text?: string}; + return content.type === "text" ? [content.text] : []; + }); + expect(userMessageTexts).not.toEqual(expect.arrayContaining(["Two"])); + expect(userMessageTexts).toEqual(expect.arrayContaining(["One", "Three"])); + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('interrupts and drops a started-but-not-inserted prompt on `$/cancel_request`', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const controller = new AbortController(); + const response = client.sendPrompt([{type: "text", text: "Hello"}], controller.signal); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + + controller.abort(); + // Its own turn (not adopted from anyone else) is interrupted, like v1's per-request cancel. + await vi.waitFor(() => expect(client.turnInterruptCalls()).toEqual([ + {threadId: "thread-1", turnId: "turn-1"}, + ])); + + await expect(response).rejects.toMatchObject({code: -32800}); + client.emit(turnFinished("interrupted")); + await client.promptRunFinished(); + await settle(); + + expect(client.transcript.some(entry => "sessionUpdate" in entry && entry.sessionUpdate.sessionUpdate === "user_message_chunk")) + .toBe(false); + // No `running`/`idle` pair: the request never got far enough to send `running`. + expect(stateUpdates(client.transcript)).toEqual([]); + }); + + it('does not interrupt an adopted turn on `$/cancel_request`, but still drops the pending request', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + // An unowned turn (e.g. a `/goal` auto-continuation) is already running. + client.emit(turnStarted("goal-turn")); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + const start = client.transcript.length; + client.setTurnStart(async () => ({turn: createTurn("inProgress", "goal-turn")})); + const controller = new AbortController(); + const response = client.sendPrompt([{type: "text", text: "Hello"}], controller.signal); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + + controller.abort(); + await expect(response).rejects.toMatchObject({code: -32800}); + await settle(); + // The adopted turn belongs to someone else (the goal continuation): a per-request cancel + // must not touch it. + expect(client.turnInterruptCalls()).toEqual([]); + + // The goal turn finishes normally on its own, unaffected by the dropped request. + client.emit(turnFinished("completed", "goal-turn")); + await client.promptRunFinished(); + await settle(); + + const transcript = client.transcript.slice(start); + expect(transcript.some(entry => "sessionUpdate" in entry && entry.sessionUpdate.sessionUpdate === "user_message_chunk")) + .toBe(false); + // This prompt claimed ownership of the adopted turn, so it alone reports the matching + // `idle` once the turn actually finishes -- with the turn's real outcome, not `cancelled`. + expect(stateUpdates(transcript)).toEqual([{state: "idle", stopReason: "end_turn"}]); + }); + + it('is a no-op for a late `$/cancel_request` after the prompt was already inserted', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const controller = new AbortController(); + const response = client.sendPrompt([{type: "text", text: "Hello"}], controller.signal); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + const {messageId} = await response; + expect(messageId).toBe(client.turnStartParams[0]!["clientUserMessageId"]); + + controller.abort(); + await settle(); + expect(client.turnInterruptCalls()).toEqual([]); + + client.emit(turnFinished("completed")); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + }); + + it('aborts a pending command approval on session/cancel and ends with exactly one idle/cancelled', async () => { + const permission = deferred(); + let capturedSignal: AbortSignal | undefined; + const client = await connectSession(2, { + onRequestPermission: async (_request, signal) => { + capturedSignal = signal; + return permission.promise; + }, + }); + closeClient = () => client.connection.close(); + + const {response, clientUserMessageId} = await startRunningPrompt(client); + const start = client.transcript.length; + + const approvalPromise = client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams()); + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + expect(capturedSignal!.aborted).toBe(false); + + await client.cancel(); + await vi.waitFor(() => expect(capturedSignal!.aborted).toBe(true)); + + // A conforming client answers `cancelled` once it has seen `session/cancel`. + permission.resolve({outcome: {outcome: "cancelled"}}); + expect(await approvalPromise).toEqual({decision: "cancel"}); + + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + client.emit(turnFinished("interrupted")); + await response; + await client.promptRunFinished(); + await settle(); + + // The trailing extra `running` (before settling to `idle`) reflects a late permission + // answer arriving after cancellation; scoping that transition is a separate concern. + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([ + {state: "requires_action"}, + {state: "running"}, + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + ]); + }); + + it('ends the plan-implementation review as idle/cancelled, not idle/end_turn, on session/cancel', async () => { + const permission = deferred(); + let capturedSignal: AbortSignal | undefined; + const client = await connectSession(2, { + onRequestPermission: async (_request, signal) => { + capturedSignal = signal; + return permission.promise; + }, + }); + closeClient = () => client.connection.close(); + + // Switch into plan mode first; `/plan` is a local command and starts no turn. + await client.sendPrompt([{type: "text", text: "/plan"}]); + await client.promptRunFinished(0); + await settle(); + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; + client.emit(itemCompleted({type: "plan", id: "plan-item", text: "1. Do the change."})); + client.emit(turnCompleted()); + + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + const start = client.transcript.length; + + // The plan turn has already completed, so `turn/interrupt` targets its stale turn id and + // has no real effect; codex-acp must still end the prompt as cancelled via `cancelRequested`. + await client.cancel(); + await vi.waitFor(() => expect(capturedSignal!.aborted).toBe(true)); + + permission.resolve({outcome: {outcome: "cancelled"}}); + await client.promptRunFinished(1); + await settle(); + + // No implementation turn is started once the review is cancelled. + expect(client.turnStartParams).toHaveLength(1); + // The leading `running` (before settling to `idle`) reflects a late permission answer + // arriving after cancellation; scoping that transition is a separate concern. + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + ]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/auth-v2-login-failed.json b/src/__tests__/CodexACPAgent/data/auth-v2-login-failed.json new file mode 100644 index 000000000..34578b41a --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/auth-v2-login-failed.json @@ -0,0 +1,18 @@ +{ + "error": { + "code": -32000, + "message": "Authentication required: Sign-in did not complete", + "data": { + "methodId": "api-key" + } + }, + "codexRequests": [ + { + "method": "account/login/start", + "params": { + "type": "apiKey", + "apiKey": "sk-test" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/auth-v2-login-then-new-session.json b/src/__tests__/CodexACPAgent/data/auth-v2-login-then-new-session.json new file mode 100644 index 000000000..4c2ce0734 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/auth-v2-login-then-new-session.json @@ -0,0 +1,33 @@ +{ + "authRequired": { + "code": -32000, + "message": "Authentication required" + }, + "login": {}, + "codexRequests": [ + { + "method": "account/login/start", + "params": { + "type": "apiKey", + "apiKey": "sk-test" + } + }, + { + "method": "thread/start", + "params": { + "config": { + "features": { + "cwd_relative_turn_diffs": false + }, + "projects": { + "/workspace": { + "trust_level": "trusted" + } + } + }, + "modelProvider": null, + "cwd": "/workspace" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/auth-v2-logout.json b/src/__tests__/CodexACPAgent/data/auth-v2-logout.json new file mode 100644 index 000000000..e98afe479 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/auth-v2-logout.json @@ -0,0 +1,8 @@ +{ + "logout": {}, + "codexRequests": [ + { + "method": "account/logout" + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/auth-v2-v1-method-names.json b/src/__tests__/CodexACPAgent/data/auth-v2-v1-method-names.json new file mode 100644 index 000000000..f05b33c89 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/auth-v2-v1-method-names.json @@ -0,0 +1,30 @@ +{ + "authenticate": { + "code": -32601, + "message": "\"Method not found\": authenticate", + "data": { + "method": "authenticate" + } + }, + "logout": { + "code": -32601, + "message": "\"Method not found\": logout", + "data": { + "method": "logout" + } + }, + "authentication/status": { + "code": -32601, + "message": "\"Method not found\": authentication/status", + "data": { + "method": "authentication/status" + } + }, + "authentication/logout": { + "code": -32601, + "message": "\"Method not found\": authentication/logout", + "data": { + "method": "authentication/logout" + } + } +} diff --git a/src/__tests__/CodexACPAgent/data/available-commands-v2-received.json b/src/__tests__/CodexACPAgent/data/available-commands-v2-received.json new file mode 100644 index 000000000..709f2fd91 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/available-commands-v2-received.json @@ -0,0 +1,30 @@ +[ + { + "sessionId": "session-1", + "update": { + "availableCommands": [ + { + "name": "review", + "description": "Review changes", + "input": { + "hint": "focus", + "type": "text" + }, + "_meta": { + "kind": "hint" + } + }, + { + "name": "status", + "description": "Show status", + "input": null + }, + { + "name": "compact", + "description": "Compact the thread" + } + ], + "sessionUpdate": "available_commands_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/available-commands-v2-rendered.json b/src/__tests__/CodexACPAgent/data/available-commands-v2-rendered.json new file mode 100644 index 000000000..02d360ce8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/available-commands-v2-rendered.json @@ -0,0 +1,25 @@ +{ + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "review", + "description": "Review changes", + "input": { + "hint": "focus", + "type": "text" + }, + "_meta": { + "kind": "hint" + } + }, + { + "name": "status", + "description": "Show status", + "input": null + }, + { + "name": "compact", + "description": "Compact the thread" + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/cancel-v2-running-prompt.json b/src/__tests__/CodexACPAgent/data/cancel-v2-running-prompt.json new file mode 100644 index 000000000..6c4f6a1b8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/cancel-v2-running-prompt.json @@ -0,0 +1,75 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "cancelled", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v1-unchanged.json b/src/__tests__/CodexACPAgent/data/diff-v1-unchanged.json new file mode 100644 index 000000000..35fef5aec --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v1-unchanged.json @@ -0,0 +1,34 @@ +[ + { + "toolCallId": "item-v1", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "path": "/workspace/v1-new-name.ts", + "oldText": "old code line\n", + "newText": "new code line\n", + "_meta": { + "kind": "update" + }, + "type": "diff" + }, + { + "path": "/workspace/v1-added.ts", + "oldText": null, + "newText": "new\n", + "_meta": { + "kind": "add" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call" + }, + { + "toolCallId": "item-v1", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v2-add-empty.json b/src/__tests__/CodexACPAgent/data/diff-v2-add-empty.json new file mode 100644 index 000000000..94feed8b4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v2-add-empty.json @@ -0,0 +1,32 @@ +[ + { + "toolCallId": "item-add-empty", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "changes": [ + { + "path": "/workspace/empty.ts", + "operation": "add" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/empty.ts b/workspace/empty.ts\nnew file mode 100644\n" + }, + "_meta": { + "kind": "add" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-add-empty", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v2-add.json b/src/__tests__/CodexACPAgent/data/diff-v2-add.json new file mode 100644 index 000000000..3019a815c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v2-add.json @@ -0,0 +1,32 @@ +[ + { + "toolCallId": "item-add", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "changes": [ + { + "path": "/workspace/added.ts", + "operation": "add" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/added.ts b/workspace/added.ts\nnew file mode 100644\n--- /dev/null\n+++ b/workspace/added.ts\n@@ -0,0 +1,2 @@\n+export const a = 1;\n+export const b = 2;\n" + }, + "_meta": { + "kind": "add" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-add", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v2-delete.json b/src/__tests__/CodexACPAgent/data/diff-v2-delete.json new file mode 100644 index 000000000..c42c2e951 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v2-delete.json @@ -0,0 +1,32 @@ +[ + { + "toolCallId": "item-delete", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "changes": [ + { + "path": "/workspace/deleted.ts", + "operation": "delete" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/deleted.ts b/workspace/deleted.ts\ndeleted file mode 100644\n--- a/workspace/deleted.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const gone = true;\n\\ No newline at end of file\n" + }, + "_meta": { + "kind": "delete" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-delete", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v2-edit.json b/src/__tests__/CodexACPAgent/data/diff-v2-edit.json new file mode 100644 index 000000000..04b837aa1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v2-edit.json @@ -0,0 +1,32 @@ +[ + { + "toolCallId": "item-edit", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "changes": [ + { + "path": "/workspace/edit.ts", + "operation": "modify" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/edit.ts b/workspace/edit.ts\n--- a/workspace/edit.ts\n+++ b/workspace/edit.ts\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n" + }, + "_meta": { + "kind": "update" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-edit", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v2-move.json b/src/__tests__/CodexACPAgent/data/diff-v2-move.json new file mode 100644 index 000000000..c0713b0a6 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v2-move.json @@ -0,0 +1,33 @@ +[ + { + "toolCallId": "item-move", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "changes": [ + { + "oldPath": "/workspace/old-name.ts", + "path": "/workspace/new-name.ts", + "operation": "move" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/old-name.ts b/workspace/new-name.ts\nrename from workspace/old-name.ts\nrename to workspace/new-name.ts\n--- a/workspace/old-name.ts\n+++ b/workspace/new-name.ts\n@@ -1 +1 @@\n-old code line\n+new code line\n" + }, + "_meta": { + "kind": "update" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-move", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v2-multi.json b/src/__tests__/CodexACPAgent/data/diff-v2-multi.json new file mode 100644 index 000000000..13f61665f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v2-multi.json @@ -0,0 +1,64 @@ +[ + { + "toolCallId": "item-multi", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "changes": [ + { + "path": "/workspace/multi-edit.ts", + "operation": "modify" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/multi-edit.ts b/workspace/multi-edit.ts\n--- a/workspace/multi-edit.ts\n+++ b/workspace/multi-edit.ts\n@@ -1 +1 @@\n-let x = 1;\n+let x = 2;\n" + }, + "_meta": { + "kind": "update" + }, + "type": "diff" + }, + { + "changes": [ + { + "path": "/workspace/multi-add.ts", + "operation": "add" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/multi-add.ts b/workspace/multi-add.ts\nnew file mode 100644\n--- /dev/null\n+++ b/workspace/multi-add.ts\n@@ -0,0 +1 @@\n+new\n" + }, + "_meta": { + "kind": "add" + }, + "type": "diff" + }, + { + "changes": [ + { + "path": "/workspace/multi-delete.ts", + "operation": "delete" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/multi-delete.ts b/workspace/multi-delete.ts\ndeleted file mode 100644\n--- a/workspace/multi-delete.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n" + }, + "_meta": { + "kind": "delete" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-multi", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/diff-v2-rename.json b/src/__tests__/CodexACPAgent/data/diff-v2-rename.json new file mode 100644 index 000000000..64e997ee9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/diff-v2-rename.json @@ -0,0 +1,33 @@ +[ + { + "toolCallId": "item-rename", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "changes": [ + { + "oldPath": "/workspace/renamed-old.ts", + "path": "/workspace/renamed-new.ts", + "operation": "move" + } + ], + "patch": { + "format": "git_patch", + "text": "diff --git a/workspace/renamed-old.ts b/workspace/renamed-new.ts\nsimilarity index 100%\nrename from workspace/renamed-old.ts\nrename to workspace/renamed-new.ts\n" + }, + "_meta": { + "kind": "update" + }, + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-rename", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/elicitation-v2-oauth-reauth-accepted.json b/src/__tests__/CodexACPAgent/data/elicitation-v2-oauth-reauth-accepted.json new file mode 100644 index 000000000..55db971f9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/elicitation-v2-oauth-reauth-accepted.json @@ -0,0 +1,24 @@ +[ + { + "sessionUpdate": { + "state": "requires_action", + "sessionUpdate": "state_update" + } + }, + { + "elicitationRequest": { + "sessionId": "thread-1", + "elicitationId": "elicit-123", + "url": "https://example.com/authorize", + "mode": "url", + "message": "Please authorize access", + "_meta": null + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/initialize-v1-routed-response.json b/src/__tests__/CodexACPAgent/data/initialize-v1-routed-response.json new file mode 100644 index 000000000..6e2c74e6f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/initialize-v1-routed-response.json @@ -0,0 +1,69 @@ +{ + "protocolVersion": 1, + "agentInfo": { + "name": "@agentclientprotocol/codex-acp", + "title": "Codex", + "version": "" + }, + "agentCapabilities": { + "auth": { + "logout": {} + }, + "providers": {}, + "loadSession": true, + "promptCapabilities": { + "embeddedContext": true, + "image": true + }, + "sessionCapabilities": { + "resume": {}, + "list": {}, + "close": {}, + "delete": {}, + "fork": {}, + "additionalDirectories": {}, + "subagents": {} + }, + "mcpCapabilities": { + "acp": false, + "http": true, + "sse": false + }, + "_meta": { + "authStatus": {} + } + }, + "authMethods": [ + { + "id": "api-key", + "name": "API Key", + "description": "Use an API key to authenticate", + "_meta": { + "api-key": { + "provider": "openai" + } + } + }, + { + "id": "chat-gpt", + "name": "ChatGPT", + "description": "Use ChatGPT to authenticate" + }, + { + "id": "gateway", + "name": "Custom model gateway", + "description": "Use a custom gateway to authenticate and access models", + "_meta": { + "gateway": { + "protocol": "openai", + "restartRequired": "false" + } + } + } + ], + "_meta": { + "steering": { + "supported": true + } + } +} diff --git a/src/__tests__/CodexACPAgent/data/initialize-v2-client-capabilities-view.json b/src/__tests__/CodexACPAgent/data/initialize-v2-client-capabilities-view.json new file mode 100644 index 000000000..071fe23b3 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/initialize-v2-client-capabilities-view.json @@ -0,0 +1,27 @@ +{ + "plan": {}, + "session": { + "compaction": {}, + "notices": {} + }, + "elicitation": { + "form": {}, + "url": {} + }, + "auth": { + "terminal": true, + "_meta": { + "gateway": true + } + }, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": [ + "nativeSubagentSessions" + ] + } + } + } +} diff --git a/src/__tests__/CodexACPAgent/data/initialize-v2-response.json b/src/__tests__/CodexACPAgent/data/initialize-v2-response.json new file mode 100644 index 000000000..1125848c2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/initialize-v2-response.json @@ -0,0 +1,94 @@ +{ + "protocolVersion": 2, + "info": { + "name": "@agentclientprotocol/codex-acp", + "title": "Codex", + "version": "" + }, + "capabilities": { + "session": { + "prompt": { + "image": {}, + "embeddedContext": {} + }, + "mcp": { + "stdio": {}, + "http": {} + }, + "delete": {}, + "additionalDirectories": {}, + "fork": {} + }, + "auth": { + "_meta": { + "authStatus": {} + } + }, + "providers": {} + }, + "authMethods": [ + { + "methodId": "api-key", + "name": "API Key", + "description": "Use an API key to authenticate", + "_meta": { + "api-key": { + "provider": "openai" + } + }, + "type": "agent" + }, + { + "methodId": "chat-gpt", + "name": "ChatGPT", + "description": "Use ChatGPT to authenticate", + "type": "agent" + }, + { + "methodId": "chat-gpt-device-code", + "name": "ChatGPT (device code)", + "description": "Sign in to ChatGPT by opening a verification page and entering a one-time code", + "type": "agent" + }, + { + "methodId": "gateway", + "name": "Custom model gateway", + "description": "Use a custom gateway to authenticate and access models", + "_meta": { + "gateway": { + "protocol": "openai", + "restartRequired": "false" + } + }, + "type": "agent" + } + ], + "_meta": { + "steering": { + "supported": true + }, + "jetbrains": { + "air": { + "version": 1, + "goal": { + "version": 1, + "controlMethod": "_session/goal", + "actions": [ + "set", + "pause", + "resume", + "clear" + ] + }, + "capabilities": [ + "sessionFailure", + "agentFileChangeReport", + "customInstructions", + "nativeSubagentSessions", + "asyncTasks", + "recommendedValue" + ] + } + } + } +} diff --git a/src/__tests__/CodexACPAgent/data/mcp-config-unsupported-transports.json b/src/__tests__/CodexACPAgent/data/mcp-config-unsupported-transports.json new file mode 100644 index 000000000..b9f031b43 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-config-unsupported-transports.json @@ -0,0 +1,22 @@ +{ + "v2 sse": { + "code": -32600, + "message": "Invalid request", + "data": "Codex doesn't support MCP SSE transport protocol" + }, + "v2 custom": { + "code": -32600, + "message": "Invalid request", + "data": "Codex doesn't support MCP '_custom' transport protocol" + }, + "v1 sse": { + "code": -32600, + "message": "Invalid request", + "data": "Codex doesn't support MCP SSE transport protocol" + }, + "v1 acp": { + "code": -32600, + "message": "Invalid request", + "data": "Codex doesn't support MCP ACP transport protocol" + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/mcp-config-v1-servers.json b/src/__tests__/CodexACPAgent/data/mcp-config-v1-servers.json new file mode 100644 index 000000000..ebe8ad559 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-config-v1-servers.json @@ -0,0 +1,17 @@ +{ + "stdio-v1": { + "command": "npx", + "args": [ + "server" + ], + "env": { + "EXAMPLE": "1" + } + }, + "http-v1": { + "url": "https://example.com/mcp", + "http_headers": { + "Authorization": "Bearer token" + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/mcp-config-v2-servers.json b/src/__tests__/CodexACPAgent/data/mcp-config-v2-servers.json new file mode 100644 index 000000000..245f1651e --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-config-v2-servers.json @@ -0,0 +1,27 @@ +{ + "stdio-full": { + "command": "/usr/local/bin/mcp-fs", + "args": [ + "--root", + "/workspace" + ], + "env": { + "EXAMPLE": "1" + } + }, + "stdio-minimal": { + "command": "/usr/local/bin/mcp-minimal", + "args": [], + "env": {} + }, + "http-full": { + "url": "https://example.com/mcp", + "http_headers": { + "Authorization": "Bearer token" + } + }, + "http-minimal": { + "url": "https://example.com/minimal", + "http_headers": {} + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/omitted-params-v2-auth-logout.json b/src/__tests__/CodexACPAgent/data/omitted-params-v2-auth-logout.json new file mode 100644 index 000000000..976c10ecf --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/omitted-params-v2-auth-logout.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": {} +} diff --git a/src/__tests__/CodexACPAgent/data/omitted-params-v2-session-list.json b/src/__tests__/CodexACPAgent/data/omitted-params-v2-session-list.json new file mode 100644 index 000000000..d4a9a8de7 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/omitted-params-v2-session-list.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "sessions": [], + "nextCursor": null + } +} diff --git a/src/__tests__/CodexACPAgent/data/permissions-v2-command-allowed.json b/src/__tests__/CodexACPAgent/data/permissions-v2-command-allowed.json new file mode 100644 index 000000000..75b57c36a --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/permissions-v2-command-allowed.json @@ -0,0 +1,55 @@ +[ + { + "sessionUpdate": { + "state": "requires_action", + "sessionUpdate": "state_update" + } + }, + { + "permissionRequest": { + "sessionId": "thread-1", + "title": "Run command?", + "subject": { + "toolCall": { + "toolCallId": "cmd-item-1", + "title": "Run command", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "ls -la", + "cwd": "/workspace" + } + }, + "type": "tool_call" + }, + "options": [ + { + "optionId": "allow_once", + "name": "Yes, proceed", + "kind": "allow_once" + }, + { + "optionId": "allow_for_session", + "name": "Yes, and don't ask again for this command in this session", + "kind": "allow_always" + }, + { + "optionId": "decline", + "name": "No, continue without running it", + "kind": "reject_once" + }, + { + "optionId": "cancel", + "name": "No, and tell Codex what to do differently", + "kind": "reject_once" + } + ] + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/permissions-v2-command-rejected.json b/src/__tests__/CodexACPAgent/data/permissions-v2-command-rejected.json new file mode 100644 index 000000000..75b57c36a --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/permissions-v2-command-rejected.json @@ -0,0 +1,55 @@ +[ + { + "sessionUpdate": { + "state": "requires_action", + "sessionUpdate": "state_update" + } + }, + { + "permissionRequest": { + "sessionId": "thread-1", + "title": "Run command?", + "subject": { + "toolCall": { + "toolCallId": "cmd-item-1", + "title": "Run command", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "ls -la", + "cwd": "/workspace" + } + }, + "type": "tool_call" + }, + "options": [ + { + "optionId": "allow_once", + "name": "Yes, proceed", + "kind": "allow_once" + }, + { + "optionId": "allow_for_session", + "name": "Yes, and don't ask again for this command in this session", + "kind": "allow_always" + }, + { + "optionId": "decline", + "name": "No, continue without running it", + "kind": "reject_once" + }, + { + "optionId": "cancel", + "name": "No, and tell Codex what to do differently", + "kind": "reject_once" + } + ] + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/permissions-v2-file-change-allowed.json b/src/__tests__/CodexACPAgent/data/permissions-v2-file-change-allowed.json new file mode 100644 index 000000000..e8865bf87 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/permissions-v2-file-change-allowed.json @@ -0,0 +1,46 @@ +[ + { + "sessionUpdate": { + "state": "requires_action", + "sessionUpdate": "state_update" + } + }, + { + "permissionRequest": { + "sessionId": "thread-1", + "title": "Make edits?", + "subject": { + "toolCall": { + "toolCallId": "edit-item-1", + "title": "Edit files", + "kind": "edit", + "status": "pending" + }, + "type": "tool_call" + }, + "options": [ + { + "optionId": "allow_once", + "name": "Yes, proceed", + "kind": "allow_once" + }, + { + "optionId": "allow_for_session", + "name": "Yes, and don't ask again for these files", + "kind": "allow_always" + }, + { + "optionId": "cancel", + "name": "No, and tell Codex what to do differently", + "kind": "reject_once" + } + ] + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/permissions-v2-plan-implementation.json b/src/__tests__/CodexACPAgent/data/permissions-v2-plan-implementation.json new file mode 100644 index 000000000..e696e855a --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/permissions-v2-plan-implementation.json @@ -0,0 +1,278 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "item/completed" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "plan": { + "planId": "plan-item", + "content": "1. Do the change.", + "type": "markdown" + }, + "sessionUpdate": "plan_update" + } + }, + { + "sessionUpdate": { + "state": "requires_action", + "sessionUpdate": "state_update" + } + }, + { + "permissionRequest": { + "sessionId": "thread-1", + "title": "Implement this plan?", + "subject": { + "toolCall": { + "toolCallId": "plan-review:plan-item", + "kind": "switch_mode", + "status": "pending", + "rawInput": { + "plan": "1. Do the change." + } + }, + "type": "tool_call" + }, + "options": [ + { + "optionId": "implement_plan", + "name": "Yes, implement this plan", + "kind": "allow_once" + }, + { + "optionId": "revise_plan", + "name": "No, and tell Codex what to do differently", + "kind": "reject_once" + } + ] + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "toolCallId": "plan-review:plan-item", + "status": "completed", + "rawOutput": "User approved the plan.", + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionUpdate": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ], + "sessionUpdate": "config_option_update" + } + }, + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Implement the approved plan.", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Implement the approved plan.", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "sessionUpdate": { + "title": "Hello", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/plans-v2-compaction-and-notice.json b/src/__tests__/CodexACPAgent/data/plans-v2-compaction-and-notice.json new file mode 100644 index 000000000..62382976e --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plans-v2-compaction-and-notice.json @@ -0,0 +1,26 @@ +[ + { + "sessionId": "session-1", + "update": { + "compactionId": "compaction-1", + "status": "in_progress", + "sessionUpdate": "compaction_update" + } + }, + { + "sessionId": "session-1", + "update": { + "compactionId": "compaction-1", + "status": "completed", + "sessionUpdate": "compaction_update" + } + }, + { + "sessionId": "session-1", + "update": { + "severity": "warning", + "title": "Optional integration unavailable", + "sessionUpdate": "notice" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/plans-v2-markdown.json b/src/__tests__/CodexACPAgent/data/plans-v2-markdown.json new file mode 100644 index 000000000..f48e455ed --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plans-v2-markdown.json @@ -0,0 +1,13 @@ +[ + { + "sessionId": "session-1", + "update": { + "plan": { + "planId": "plan-item-1", + "content": "1. Add the mapping.", + "type": "markdown" + }, + "sessionUpdate": "plan_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/plans-v2-structured.json b/src/__tests__/CodexACPAgent/data/plans-v2-structured.json new file mode 100644 index 000000000..197b1ab04 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plans-v2-structured.json @@ -0,0 +1,46 @@ +[ + { + "sessionId": "session-1", + "update": { + "plan": { + "planId": "codex-structured-plan", + "entries": [ + { + "content": "Add the mapping", + "priority": "medium", + "status": "in_progress" + }, + { + "content": "Verify it", + "priority": "medium", + "status": "pending" + } + ], + "type": "items" + }, + "sessionUpdate": "plan_update" + } + }, + { + "sessionId": "session-1", + "update": { + "plan": { + "planId": "codex-structured-plan", + "entries": [ + { + "content": "Add the mapping", + "priority": "medium", + "status": "completed" + }, + { + "content": "Verify it", + "priority": "medium", + "status": "in_progress" + } + ], + "type": "items" + }, + "sessionUpdate": "plan_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-compact-command.json b/src/__tests__/CodexACPAgent/data/prompt-v2-compact-command.json new file mode 100644 index 000000000..7858845d1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-compact-command.json @@ -0,0 +1,42 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/compact", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-compact-failed-after-insertion.json b/src/__tests__/CodexACPAgent/data/prompt-v2-compact-failed-after-insertion.json new file mode 100644 index 000000000..7858845d1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-compact-failed-after-insertion.json @@ -0,0 +1,42 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/compact", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-compact-turn-start-rejected.json b/src/__tests__/CodexACPAgent/data/prompt-v2-compact-turn-start-rejected.json new file mode 100644 index 000000000..d03a406ca --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-compact-turn-start-rejected.json @@ -0,0 +1,11 @@ +[ + { + "promptError": { + "code": -32603, + "message": "Internal error", + "data": { + "details": "Cannot compact now" + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-goal-command.json b/src/__tests__/CodexACPAgent/data/prompt-v2-goal-command.json new file mode 100644 index 000000000..579011fa3 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-goal-command.json @@ -0,0 +1,45 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/goal Ship it", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "thread/goal/updated" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-goal-resume-command.json b/src/__tests__/CodexACPAgent/data/prompt-v2-goal-resume-command.json new file mode 100644 index 000000000..b80397473 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-goal-resume-command.json @@ -0,0 +1,45 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/goal resume", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "thread/goal/updated" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-goal-resume-no-continuation.json b/src/__tests__/CodexACPAgent/data/prompt-v2-goal-resume-no-continuation.json new file mode 100644 index 000000000..c691bad87 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-goal-resume-no-continuation.json @@ -0,0 +1,39 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/goal resume", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "thread/goal/updated" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-idle-usage-and-quota.json b/src/__tests__/CodexACPAgent/data/prompt-v2-idle-usage-and-quota.json new file mode 100644 index 000000000..5f2eb0c5b --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-idle-usage-and-quota.json @@ -0,0 +1,38 @@ +{ + "stopReason": "end_turn", + "usage": { + "totalTokens": 1200, + "inputTokens": 600, + "outputTokens": 200, + "thoughtTokens": 50, + "cachedReadTokens": 400, + "cachedWriteTokens": 0 + }, + "_meta": { + "quota": { + "token_count": { + "totalTokens": 1200, + "inputTokens": 600, + "cachedInputTokens": 400, + "cacheWriteInputTokens": 0, + "outputTokens": 200, + "reasoningOutputTokens": 50 + }, + "model_usage": [ + { + "model": "gpt-5", + "token_count": { + "totalTokens": 1200, + "inputTokens": 600, + "cachedInputTokens": 400, + "cacheWriteInputTokens": 0, + "outputTokens": 200, + "reasoningOutputTokens": 50 + } + } + ] + } + }, + "state": "idle", + "sessionUpdate": "state_update" +} diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-inserted.json b/src/__tests__/CodexACPAgent/data/prompt-v2-inserted.json new file mode 100644 index 000000000..17e167920 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-inserted.json @@ -0,0 +1,87 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "codexNotification": "item/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "title": "Hello", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-local-command-failed.json b/src/__tests__/CodexACPAgent/data/prompt-v2-local-command-failed.json new file mode 100644 index 000000000..3ad2353b6 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-local-command-failed.json @@ -0,0 +1,46 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/rename New name", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "The '/rename' command failed: thread name could not be saved", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-local-command-reply.json b/src/__tests__/CodexACPAgent/data/prompt-v2-local-command-reply.json new file mode 100644 index 000000000..41fa578f9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-local-command-reply.json @@ -0,0 +1,46 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/skills", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "No skills configured.", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-local-command.json b/src/__tests__/CodexACPAgent/data/prompt-v2-local-command.json new file mode 100644 index 000000000..5d997c110 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-local-command.json @@ -0,0 +1,122 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/plan", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "plan", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ], + "sessionUpdate": "config_option_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-not-inserted.json b/src/__tests__/CodexACPAgent/data/prompt-v2-not-inserted.json new file mode 100644 index 000000000..81197a86b --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-not-inserted.json @@ -0,0 +1,44 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "turn/completed" + }, + { + "promptError": { + "code": -32603, + "message": "Internal error: The prompt ended before Codex recorded the user message" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-plan-implementation-turn.json b/src/__tests__/CodexACPAgent/data/prompt-v2-plan-implementation-turn.json new file mode 100644 index 000000000..1a469c04d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-plan-implementation-turn.json @@ -0,0 +1,229 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "item/completed" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "plan": { + "planId": "plan-item", + "content": "1. Do the change.", + "type": "markdown" + }, + "sessionUpdate": "plan_update" + } + }, + { + "sessionUpdate": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ], + "sessionUpdate": "config_option_update" + } + }, + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Implement the approved plan.", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Implement the approved plan.", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "title": "Hello", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-process-exit-after-insertion.json b/src/__tests__/CodexACPAgent/data/prompt-v2-process-exit-after-insertion.json new file mode 100644 index 000000000..e867578a8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-process-exit-after-insertion.json @@ -0,0 +1,178 @@ +[ + { + "typed": false, + "transcript": [ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Codex process has exited with code 1", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } + ] + }, + { + "typed": true, + "transcript": [ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + }, + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "turn-1:error", + "revision": 1, + "category": "connection", + "severity": "error", + "title": "Connection to Codex was lost.", + "actions": [ + "retry", + "new_session" + ] + } + } + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } + ] + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-review-command.json b/src/__tests__/CodexACPAgent/data/prompt-v2-review-command.json new file mode 100644 index 000000000..288ac6108 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-review-command.json @@ -0,0 +1,48 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/review", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "codexNotification": "item/completed" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-review-failed-after-insertion.json b/src/__tests__/CodexACPAgent/data/prompt-v2-review-failed-after-insertion.json new file mode 100644 index 000000000..da458144d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-review-failed-after-insertion.json @@ -0,0 +1,42 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/review", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-review-turn-start-rejected.json b/src/__tests__/CodexACPAgent/data/prompt-v2-review-turn-start-rejected.json new file mode 100644 index 000000000..a6c907158 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-review-turn-start-rejected.json @@ -0,0 +1,11 @@ +[ + { + "promptError": { + "code": -32603, + "message": "Internal error", + "data": { + "details": "Cannot review right now" + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-review-unspawned-error.json b/src/__tests__/CodexACPAgent/data/prompt-v2-review-unspawned-error.json new file mode 100644 index 000000000..1f511e3a2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-review-unspawned-error.json @@ -0,0 +1,49 @@ +[ + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/review", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "error" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "/test/cwd is not a git repository\n\n", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-turn-error-after-insertion.json b/src/__tests__/CodexACPAgent/data/prompt-v2-turn-error-after-insertion.json new file mode 100644 index 000000000..aa4bcdb2d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-turn-error-after-insertion.json @@ -0,0 +1,184 @@ +[ + { + "info": "usageLimitExceeded", + "transcript": [ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "error" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "You've hit your usage limit.\n\n", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } + ] + }, + { + "info": "unauthorized", + "transcript": [ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "error" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Authentication required", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } + ] + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-turn-failed-or-interrupted.json b/src/__tests__/CodexACPAgent/data/prompt-v2-turn-failed-or-interrupted.json new file mode 100644 index 000000000..588394aea --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-turn-failed-or-interrupted.json @@ -0,0 +1,164 @@ +[ + { + "status": "failed", + "transcript": [ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "title": "Hello", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } + ] + }, + { + "status": "interrupted", + "transcript": [ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "cancelled", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } + ] + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-turn-start-rejected.json b/src/__tests__/CodexACPAgent/data/prompt-v2-turn-start-rejected.json new file mode 100644 index 000000000..285f44ac1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-turn-start-rejected.json @@ -0,0 +1,38 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "promptError": { + "code": -32603, + "message": "Internal error", + "data": { + "details": "thread not found: thread-1" + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-typed-failure-after-insertion.json b/src/__tests__/CodexACPAgent/data/prompt-v2-typed-failure-after-insertion.json new file mode 100644 index 000000000..7272b2017 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-typed-failure-after-insertion.json @@ -0,0 +1,91 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "error" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + }, + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "turn-1:error", + "revision": 1, + "category": "limit", + "severity": "error", + "title": "You've hit your usage limit.", + "actions": [] + } + } + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-unsupported-content.json b/src/__tests__/CodexACPAgent/data/prompt-v2-unsupported-content.json new file mode 100644 index 000000000..14148f876 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-unsupported-content.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Invalid params: Unsupported content block type: _custom" +} diff --git a/src/__tests__/CodexACPAgent/data/prompt-v2-v1-unchanged.json b/src/__tests__/CodexACPAgent/data/prompt-v2-v1-unchanged.json new file mode 100644 index 000000000..cc031b67c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/prompt-v2-v1-unchanged.json @@ -0,0 +1,58 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "title": "Hello", + "sessionUpdate": "session_info_update" + } + }, + { + "promptResponse": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + } + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/review-turn-completed.json b/src/__tests__/CodexACPAgent/data/review-turn-completed.json new file mode 100644 index 000000000..d0dc26ec7 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-turn-completed.json @@ -0,0 +1,22 @@ +{ + "updates": [ + { + "sessionUpdate": "agent_message_chunk", + "messageId": "agent-message", + "content": { + "type": "text", + "text": "Looks good." + } + } + ], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/review-turn-error.json b/src/__tests__/CodexACPAgent/data/review-turn-error.json new file mode 100644 index 000000000..86759831f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-turn-error.json @@ -0,0 +1,21 @@ +{ + "updates": [ + { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Codex is temporarily overloaded.\n\n" + } + } + ], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/review-turn-typed-failure.json b/src/__tests__/CodexACPAgent/data/review-turn-typed-failure.json new file mode 100644 index 000000000..b7bf5e3d2 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-turn-typed-failure.json @@ -0,0 +1,28 @@ +{ + "updates": [], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + }, + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "review-parent-turn:error", + "revision": 1, + "category": "service", + "severity": "error", + "title": "Codex is temporarily overloaded.", + "actions": [ + "retry" + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/review-unspawned-error-next-prompt-fails.json b/src/__tests__/CodexACPAgent/data/review-unspawned-error-next-prompt-fails.json new file mode 100644 index 000000000..9b0eff3c8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-unspawned-error-next-prompt-fails.json @@ -0,0 +1,33 @@ +{ + "updates": [ + { + "sessionUpdate": "agent_message_chunk", + "messageId": "next-message", + "content": { + "type": "text", + "text": "Hi." + } + }, + { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "/test/cwd is not a git repository\n\n" + } + }, + { + "sessionUpdate": "session_info_update", + "title": "hello" + } + ], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/review-unspawned-error-next-prompt.json b/src/__tests__/CodexACPAgent/data/review-unspawned-error-next-prompt.json new file mode 100644 index 000000000..e8250d3eb --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-unspawned-error-next-prompt.json @@ -0,0 +1,26 @@ +{ + "updates": [ + { + "sessionUpdate": "agent_message_chunk", + "messageId": "next-message", + "content": { + "type": "text", + "text": "Hi." + } + }, + { + "sessionUpdate": "session_info_update", + "title": "hello" + } + ], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/review-unspawned-error.json b/src/__tests__/CodexACPAgent/data/review-unspawned-error.json new file mode 100644 index 000000000..789664cb8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-unspawned-error.json @@ -0,0 +1,21 @@ +{ + "updates": [ + { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "/test/cwd is not a git repository\n\n" + } + } + ], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/review-unspawned-typed-failure-next-prompt.json b/src/__tests__/CodexACPAgent/data/review-unspawned-typed-failure-next-prompt.json new file mode 100644 index 000000000..e8250d3eb --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-unspawned-typed-failure-next-prompt.json @@ -0,0 +1,26 @@ +{ + "updates": [ + { + "sessionUpdate": "agent_message_chunk", + "messageId": "next-message", + "content": { + "type": "text", + "text": "Hi." + } + }, + { + "sessionUpdate": "session_info_update", + "title": "hello" + } + ], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/review-unspawned-typed-failure.json b/src/__tests__/CodexACPAgent/data/review-unspawned-typed-failure.json new file mode 100644 index 000000000..2c32e17a8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/review-unspawned-typed-failure.json @@ -0,0 +1,28 @@ +{ + "updates": [], + "response": { + "stopReason": "end_turn", + "usage": null, + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + }, + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "review-parent-turn:error", + "revision": 1, + "category": "service", + "severity": "error", + "title": "/test/cwd is not a git repository", + "actions": [ + "retry" + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/session-config-options-v2-list.json b/src/__tests__/CodexACPAgent/data/session-config-options-v2-list.json new file mode 100644 index 000000000..cbe3cbbe0 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-config-options-v2-list.json @@ -0,0 +1,133 @@ +{ + "configOptions": [ + { + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "auto_review" + } + } + } + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "full_access" + } + } + } + } + ] + }, + { + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ] + }, + { + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model", + "type": "select", + "currentValue": "fast-model", + "options": [ + { + "value": "fast-model", + "name": "Fast model", + "description": "Frontier" + }, + { + "value": "slow-model", + "name": "Slow model", + "description": "slow-model model" + } + ] + }, + { + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low", + "description": "Fast" + }, + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ] + }, + { + "configId": "fast-mode", + "name": "Fast mode", + "description": "1.5x speed, increased usage", + "category": "model_config", + "type": "boolean", + "currentValue": false + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-config-options-v2-set.json b/src/__tests__/CodexACPAgent/data/session-config-options-v2-set.json new file mode 100644 index 000000000..99684b220 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-config-options-v2-set.json @@ -0,0 +1,268 @@ +{ + "fastModeResponse": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "auto_review" + } + } + } + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "full_access" + } + } + } + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "fast-model", + "options": [ + { + "value": "fast-model", + "name": "Fast model", + "description": "Frontier" + }, + { + "value": "slow-model", + "name": "Slow model", + "description": "slow-model model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low", + "description": "Fast" + }, + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + }, + { + "currentValue": true, + "type": "boolean", + "configId": "fast-mode", + "name": "Fast mode", + "description": "1.5x speed, increased usage", + "category": "model_config" + } + ] + }, + "modeResponse": { + "configOptions": [ + { + "currentValue": "read-only", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "auto_review" + } + } + } + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "full_access" + } + } + } + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "fast-model", + "options": [ + { + "value": "fast-model", + "name": "Fast model", + "description": "Frontier" + }, + { + "value": "slow-model", + "name": "Slow model", + "description": "slow-model model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low", + "description": "Fast" + }, + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + }, + { + "currentValue": true, + "type": "boolean", + "configId": "fast-mode", + "name": "Fast mode", + "description": "1.5x speed, increased usage", + "category": "model_config" + } + ] + } +} diff --git a/src/__tests__/CodexACPAgent/data/session-config-options-v2-update.json b/src/__tests__/CodexACPAgent/data/session-config-options-v2-update.json new file mode 100644 index 000000000..4e2b2244c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-config-options-v2-update.json @@ -0,0 +1,157 @@ +[ + { + "sessionId": "session-1", + "update": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network.", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "standard" + } + } + } + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "auto_review" + } + } + } + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "kind": "full_access" + } + } + } + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "fast-model", + "options": [ + { + "value": "fast-model", + "name": "Fast model", + "description": "Frontier" + }, + { + "value": "slow-model", + "name": "Slow model", + "description": "slow-model model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low", + "description": "Fast" + }, + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + }, + { + "currentValue": false, + "type": "boolean", + "configId": "fast-mode", + "name": "Fast mode", + "description": "1.5x speed, increased usage", + "category": "model_config" + }, + { + "currentValue": "a", + "options": [ + { + "groupId": "letters", + "name": "Letters", + "options": [ + { + "value": "a", + "name": "A" + } + ] + } + ], + "type": "select", + "configId": "grouped", + "name": "Grouped" + } + ], + "sessionUpdate": "config_option_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-close.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-close.json new file mode 100644 index 000000000..9b4d093d8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-close.json @@ -0,0 +1,11 @@ +{ + "response": {}, + "codexRequests": [ + { + "method": "thread/unsubscribe", + "params": { + "threadId": "thread-1" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-delete.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-delete.json new file mode 100644 index 000000000..2dd54d21f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-delete.json @@ -0,0 +1,17 @@ +{ + "response": {}, + "codexRequests": [ + { + "method": "thread/unsubscribe", + "params": { + "threadId": "thread-1" + } + }, + { + "method": "thread/archive", + "params": { + "threadId": "thread-1" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork-invalid-params.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork-invalid-params.json new file mode 100644 index 000000000..db06d217e --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork-invalid-params.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Invalid params: Fork point message missing-item was not found in session thread-1" +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork-unknown-session.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork-unknown-session.json new file mode 100644 index 000000000..2560c88d3 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork-unknown-session.json @@ -0,0 +1,4 @@ +{ + "code": -32603, + "message": "Internal error" +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork.json new file mode 100644 index 000000000..3e353ecb7 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-fork.json @@ -0,0 +1,107 @@ +{ + "response": { + "sessionId": "fork-1", + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ] + }, + "codexRequests": [ + { + "method": "thread/fork", + "params": { + "excludeTurns": true, + "config": { + "features": { + "cwd_relative_turn_diffs": false + }, + "projects": { + "/workspace": { + "trust_level": "trusted" + } + } + }, + "cwd": "/workspace", + "modelProvider": "openai", + "threadId": "thread-1" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-list.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-list.json new file mode 100644 index 000000000..7496a3975 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-list.json @@ -0,0 +1,30 @@ +{ + "response": { + "sessions": [ + { + "sessionId": "thread-1", + "cwd": "/workspace", + "title": "Earlier session", + "updatedAt": "1970-01-01T00:03:20.000Z" + } + ], + "nextCursor": null + }, + "codexRequests": [ + { + "method": "thread/list", + "params": { + "cursor": null, + "modelProviders": [], + "sourceKinds": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ], + "useStateDbOnly": true + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-new-mcp-servers.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-new-mcp-servers.json new file mode 100644 index 000000000..9a6d80381 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-new-mcp-servers.json @@ -0,0 +1,33 @@ +[ + { + "method": "thread/start", + "params": { + "config": { + "features": { + "cwd_relative_turn_diffs": false + }, + "projects": { + "/workspace": { + "trust_level": "trusted" + } + }, + "mcp_servers": { + "files": { + "command": "/usr/local/bin/mcp-fs", + "args": [ + "--root", + "/workspace" + ], + "env": {} + }, + "remote": { + "url": "https://example.com/mcp", + "http_headers": {} + } + } + }, + "modelProvider": null, + "cwd": "/workspace" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-new.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-new.json new file mode 100644 index 000000000..f05a04c03 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-new.json @@ -0,0 +1,188 @@ +{ + "response": { + "sessionId": "thread-1", + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ] + }, + "codexRequests": [ + { + "method": "thread/start", + "params": { + "config": { + "features": { + "cwd_relative_turn_diffs": false + }, + "projects": { + "/workspace": { + "trust_level": "trusted" + } + } + }, + "modelProvider": null, + "cwd": "/workspace" + } + } + ], + "updates": [ + { + "sessionId": "thread-1", + "update": { + "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null + }, + { + "name": "mcp", + "description": "Show the status of the MCP servers, or reconnect them.", + "input": { + "hint": "[reconnect]", + "type": "text" + } + }, + { + "name": "skills", + "description": "List available skills.", + "input": null + }, + { + "name": "status", + "description": "Display session configuration and token usage.", + "input": null + }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions", + "type": "text" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name", + "type": "text" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha", + "type": "text" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, + { + "name": "goal", + "description": "Set a goal to keep pursuing.", + "input": { + "hint": "[|clear|pause|resume]", + "type": "text" + } + }, + { + "name": "rename", + "description": "Rename the current session.", + "input": { + "hint": "new name", + "type": "text" + } + }, + { + "name": "logout", + "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", + "input": null + } + ], + "sessionUpdate": "available_commands_update" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-null-replay.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-null-replay.json new file mode 100644 index 000000000..8837b75f1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-null-replay.json @@ -0,0 +1,106 @@ +{ + "response": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ] + }, + "codexRequests": [ + { + "method": "thread/resume", + "params": { + "excludeTurns": true, + "config": { + "features": { + "cwd_relative_turn_diffs": false + }, + "projects": { + "/workspace": { + "trust_level": "trusted" + } + } + }, + "cwd": "/workspace", + "modelProvider": "openai", + "threadId": "thread-1" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-start-replay.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-start-replay.json new file mode 100644 index 000000000..fe8141189 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-start-replay.json @@ -0,0 +1,296 @@ +{ + "response": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ] + }, + "codexRequests": [ + { + "method": "thread/resume", + "params": { + "excludeTurns": true, + "config": { + "features": { + "cwd_relative_turn_diffs": false + }, + "projects": { + "/workspace": { + "trust_level": "trusted" + } + } + }, + "cwd": "/workspace", + "modelProvider": "openai", + "threadId": "thread-1" + } + } + ], + "updates": [ + { + "sessionId": "thread-1", + "update": { + "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null + }, + { + "name": "mcp", + "description": "Show the status of the MCP servers, or reconnect them.", + "input": { + "hint": "[reconnect]", + "type": "text" + } + }, + { + "name": "skills", + "description": "List available skills.", + "input": null + }, + { + "name": "status", + "description": "Display session configuration and token usage.", + "input": null + }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions", + "type": "text" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name", + "type": "text" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha", + "type": "text" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, + { + "name": "goal", + "description": "Set a goal to keep pursuing.", + "input": { + "hint": "[|clear|pause|resume]", + "type": "text" + } + }, + { + "name": "rename", + "description": "Rename the current session.", + "input": { + "hint": "new name", + "type": "text" + } + }, + { + "name": "logout", + "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", + "input": null + } + ], + "sessionUpdate": "available_commands_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "title": "Inserted via session/prompt", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "client-user-1", + "content": [], + "sessionUpdate": "user_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "client-user-1", + "content": { + "text": "Inserted via session/prompt", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "item-user-2", + "content": [], + "sessionUpdate": "user_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "item-user-2", + "content": { + "text": "A legacy message with no clientId", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "item-agent-1", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "item-agent-1", + "content": { + "text": "Hello!", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "terminalId": "item-cmd-1", + "command": "ls", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "toolCallId": "item-cmd-1", + "title": "ls", + "kind": "execute", + "status": "completed", + "content": [ + { + "terminalId": "item-cmd-1", + "type": "terminal" + } + ], + "rawInput": { + "command": "ls", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "thread-1", + "update": { + "terminalId": "item-cmd-1", + "output": { + "data": "UkVBRE1FLm1kCg==" + }, + "exitStatus": { + "exitCode": 0, + "signal": null + }, + "sessionUpdate": "terminal_update" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-unknown-replay.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-unknown-replay.json new file mode 100644 index 000000000..be326f275 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume-unknown-replay.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Invalid params: Unsupported replayFrom type: _checkpoint" +} diff --git a/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume.json b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume.json new file mode 100644 index 000000000..31bfacf14 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-lifecycle-v2-resume.json @@ -0,0 +1,189 @@ +{ + "response": { + "configOptions": [ + { + "currentValue": "agent", + "options": [ + { + "value": "read-only", + "name": "Read-only", + "description": "Requires approval to edit files and access the internet." + }, + { + "value": "workspace-write", + "name": "Workspace access", + "description": "Edit workspace files; ask before writing outside the workspace or accessing the network." + }, + { + "value": "agent", + "name": "Auto review", + "description": "Only ask for actions detected as potentially unsafe" + }, + { + "value": "agent-full-access", + "name": "Full access", + "description": "Unrestricted access to the internet and any file on your computer" + } + ], + "type": "select", + "configId": "mode", + "name": "Mode", + "description": "Approval and sandboxing preset for the session", + "category": "mode" + }, + { + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default" + }, + { + "value": "plan", + "name": "Plan", + "description": "Plan before making changes" + } + ], + "type": "select", + "configId": "collaboration_mode", + "name": "Collaboration mode", + "description": "How Codex collaborates for subsequent turns", + "category": "collaboration_mode" + }, + { + "currentValue": "gpt-5", + "options": [ + { + "value": "gpt-5", + "name": "5", + "description": "gpt-5 model" + } + ], + "type": "select", + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the session", + "category": "model" + }, + { + "currentValue": "medium", + "options": [ + { + "value": "medium", + "name": "Medium", + "description": "Balanced" + } + ], + "type": "select", + "configId": "reasoning_effort", + "name": "Reasoning effort", + "description": "How much reasoning effort the model should use", + "category": "thought_level" + } + ] + }, + "codexRequests": [ + { + "method": "thread/resume", + "params": { + "excludeTurns": true, + "config": { + "features": { + "cwd_relative_turn_diffs": false + }, + "projects": { + "/workspace": { + "trust_level": "trusted" + } + } + }, + "cwd": "/workspace", + "modelProvider": "openai", + "threadId": "thread-1" + } + } + ], + "updates": [ + { + "sessionId": "thread-1", + "update": { + "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null + }, + { + "name": "mcp", + "description": "Show the status of the MCP servers, or reconnect them.", + "input": { + "hint": "[reconnect]", + "type": "text" + } + }, + { + "name": "skills", + "description": "List available skills.", + "input": null + }, + { + "name": "status", + "description": "Display session configuration and token usage.", + "input": null + }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions", + "type": "text" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name", + "type": "text" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha", + "type": "text" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, + { + "name": "goal", + "description": "Set a goal to keep pursuing.", + "input": { + "hint": "[|clear|pause|resume]", + "type": "text" + } + }, + { + "name": "rename", + "description": "Rename the current session.", + "input": { + "hint": "new name", + "type": "text" + } + }, + { + "name": "logout", + "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", + "input": null + } + ], + "sessionUpdate": "available_commands_update" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/session-update-v1-routed.json b/src/__tests__/CodexACPAgent/data/session-update-v1-routed.json new file mode 100644 index 000000000..16b8f0978 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-update-v1-routed.json @@ -0,0 +1,22 @@ +[ + { + "sessionId": "session-1", + "update": { + "toolCallId": "call-1", + "title": "Run tests", + "sessionUpdate": "tool_call" + } + }, + { + "sessionId": "session-1", + "update": { + "used": 1200, + "size": 200000, + "cost": { + "amount": 0.25, + "currency": "USD" + }, + "sessionUpdate": "usage_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-update-v2-pass-through.json b/src/__tests__/CodexACPAgent/data/session-update-v2-pass-through.json new file mode 100644 index 000000000..0b2555439 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-update-v2-pass-through.json @@ -0,0 +1,21 @@ +[ + { + "sessionId": "session-1", + "update": { + "used": 1200, + "size": 200000, + "cost": { + "amount": 0.25, + "currency": "USD" + }, + "sessionUpdate": "usage_update" + } + }, + { + "sessionId": "session-1", + "update": { + "title": "Fix the build", + "sessionUpdate": "session_info_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-update-v2-subagent-and-async-task.json b/src/__tests__/CodexACPAgent/data/session-update-v2-subagent-and-async-task.json new file mode 100644 index 000000000..f84ecf4f5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-update-v2-subagent-and-async-task.json @@ -0,0 +1,41 @@ +[ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "_subagent_update", + "subagentSessionId": "child-1", + "name": "Researcher", + "task": "Look into flaky tests", + "capabilities": {} + } + }, + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "_subagent_update", + "subagentSessionId": "child-1", + "state": "completed" + } + }, + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "_async_task_spawned", + "asyncTaskId": "task-1", + "name": "npm test", + "taskType": "shell", + "showInTranscript": false, + "canStop": true, + "toolCallId": "call-1" + } + }, + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "_async_task_state_update", + "asyncTaskId": "task-1", + "state": "completed", + "toolCallId": "call-1" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/session-update-v2-tool-calls-and-chunks.json b/src/__tests__/CodexACPAgent/data/session-update-v2-tool-calls-and-chunks.json new file mode 100644 index 000000000..e477265d8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-update-v2-tool-calls-and-chunks.json @@ -0,0 +1,49 @@ +[ + { + "sessionId": "session-1", + "update": { + "toolCallId": "call-1", + "title": "Run tests", + "kind": "execute", + "status": "pending", + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "session-1", + "update": { + "toolCallId": "call-1", + "status": "completed", + "content": [ + { + "content": { + "text": "ok", + "type": "text" + }, + "type": "content" + } + ], + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "session-1", + "update": { + "toolCallId": "call-1", + "content": null, + "locations": null, + "sessionUpdate": "tool_call_update" + } + }, + { + "sessionId": "session-1", + "update": { + "messageId": "item-1", + "content": { + "text": "hi", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/steering-v2-fallback-new-turn.json b/src/__tests__/CodexACPAgent/data/steering-v2-fallback-new-turn.json new file mode 100644 index 000000000..847db402c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/steering-v2-fallback-new-turn.json @@ -0,0 +1,70 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "start fresh", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "start fresh", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "state": "idle", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "title": "start fresh", + "sessionUpdate": "session_info_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/steering-v2-injected-owned-turn.json b/src/__tests__/CodexACPAgent/data/steering-v2-injected-owned-turn.json new file mode 100644 index 000000000..500d86e0b --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/steering-v2-injected-owned-turn.json @@ -0,0 +1,94 @@ +[ + { + "codexRequest": "turn/start", + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "Hello", + "text_elements": [] + } + ], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "summary": "auto", + "effort": "medium", + "model": "gpt-5", + "serviceTier": null, + "clientUserMessageId": "" + } + }, + { + "codexResponse": "turn/start" + }, + { + "codexNotification": "turn/started" + }, + { + "codexNotification": "item/completed" + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "Hello", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "promptResponse": { + "messageId": "" + } + }, + { + "codexNotification": "item/started" + }, + { + "sessionUpdate": { + "state": "running", + "sessionUpdate": "state_update" + } + }, + { + "sessionUpdate": { + "messageId": "", + "content": { + "text": "also do this", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "codexNotification": "turn/completed" + }, + { + "sessionUpdate": { + "title": "Hello", + "sessionUpdate": "session_info_update" + } + }, + { + "sessionUpdate": { + "stopReason": "end_turn", + "_meta": { + "quota": { + "token_count": null, + "model_usage": [] + } + }, + "state": "idle", + "sessionUpdate": "state_update" + } + } +] diff --git a/src/__tests__/CodexACPAgent/data/terminal-v2-failed.json b/src/__tests__/CodexACPAgent/data/terminal-v2-failed.json new file mode 100644 index 000000000..1f52a4220 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-v2-failed.json @@ -0,0 +1,44 @@ +[ + { + "terminalId": "item-shell", + "command": "npm test", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + }, + { + "toolCallId": "item-shell", + "title": "npm test", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "item-shell", + "type": "terminal" + } + ], + "rawInput": { + "command": "npm test", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_update" + }, + { + "terminalId": "item-shell", + "data": "bnBtIEVSUiEgVGVzdCBmYWlsZWQuCg==", + "sessionUpdate": "terminal_output_chunk", + "decoded": "npm ERR! Test failed.\n" + }, + { + "terminalId": "item-shell", + "exitStatus": { + "exitCode": 1, + "signal": null + }, + "sessionUpdate": "terminal_update" + }, + { + "toolCallId": "item-shell", + "status": "failed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/terminal-v2-no-terminal.json b/src/__tests__/CodexACPAgent/data/terminal-v2-no-terminal.json new file mode 100644 index 000000000..a22c10be5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-v2-no-terminal.json @@ -0,0 +1,31 @@ +[ + { + "toolCallId": "item-read", + "title": "Read file '/workspace/src/a.ts'", + "kind": "read", + "status": "in_progress", + "locations": [ + { + "path": "/workspace/src/a.ts" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-read", + "status": "completed", + "content": [ + { + "content": { + "text": "export {};\n", + "type": "text" + }, + "type": "content" + } + ], + "rawOutput": { + "exit_code": 0 + }, + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/terminal-v2-snapshot.json b/src/__tests__/CodexACPAgent/data/terminal-v2-snapshot.json new file mode 100644 index 000000000..05c76c017 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-v2-snapshot.json @@ -0,0 +1,42 @@ +[ + { + "terminalId": "item-shell", + "command": "npm test", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + }, + { + "toolCallId": "item-shell", + "title": "npm test", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "item-shell", + "type": "terminal" + } + ], + "rawInput": { + "command": "/bin/zsh -lc 'npm test'", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_update" + }, + { + "terminalId": "item-shell", + "output": { + "data": "aGVsbG8K" + }, + "exitStatus": { + "exitCode": 0, + "signal": null + }, + "sessionUpdate": "terminal_update", + "decodedOutput": "hello\n" + }, + { + "toolCallId": "item-shell", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/terminal-v2-streamed.json b/src/__tests__/CodexACPAgent/data/terminal-v2-streamed.json new file mode 100644 index 000000000..d148822b4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-v2-streamed.json @@ -0,0 +1,56 @@ +[ + { + "terminalId": "item-shell", + "command": "npm test", + "cwd": "/workspace", + "sessionUpdate": "terminal_update" + }, + { + "toolCallId": "item-shell", + "title": "npm test", + "kind": "execute", + "status": "in_progress", + "content": [ + { + "terminalId": "item-shell", + "type": "terminal" + } + ], + "rawInput": { + "command": "npm test", + "cwd": "/workspace" + }, + "sessionUpdate": "tool_call_update" + }, + { + "terminalId": "item-shell", + "data": "UEFTUyBhLnRlc3QudHMK", + "sessionUpdate": "terminal_output_chunk", + "decoded": "PASS a.test.ts\n" + }, + { + "terminalId": "item-shell", + "data": "VGVzdHM6IDEgcGFzc2VkIOKckwo=", + "sessionUpdate": "terminal_output_chunk", + "decoded": "Tests: 1 passed ✓\n" + }, + { + "terminalId": "item-shell", + "data": "CnEK", + "sessionUpdate": "terminal_output_chunk", + "decoded": "\nq\n" + }, + { + "terminalId": "item-shell", + "exitStatus": { + "exitCode": 0, + "signal": null + }, + "sessionUpdate": "terminal_update" + }, + { + "toolCallId": "item-shell", + "status": "completed", + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-mcp-startup.json b/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-mcp-startup.json new file mode 100644 index 000000000..4d1cf2ff8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-mcp-startup.json @@ -0,0 +1,34 @@ +[ + { + "toolCallId": "mcp_startup.docs.", + "title": "mcp__docs__startup", + "kind": "other", + "status": "failed", + "content": [ + { + "content": { + "text": "[codex-acp forwarded startup error] MCP server `docs` failed to start: exit code 1", + "type": "text" + }, + "type": "content" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "mcp_startup.slow.", + "title": "mcp__slow__startup", + "kind": "other", + "status": "failed", + "content": [ + { + "content": { + "text": "[codex-acp forwarded startup error] MCP server `slow` startup was cancelled.", + "type": "text" + }, + "type": "content" + } + ], + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-message-ids.json b/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-message-ids.json new file mode 100644 index 000000000..59294c6c5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-message-ids.json @@ -0,0 +1,42 @@ +[ + { + "messageId": "item-reasoning", + "content": { + "text": "Thinking ", + "type": "text" + }, + "sessionUpdate": "agent_thought_chunk" + }, + { + "messageId": "item-reasoning", + "content": { + "text": "hard", + "type": "text" + }, + "sessionUpdate": "agent_thought_chunk" + }, + { + "messageId": "item-agent", + "content": { + "text": "Hel", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + }, + { + "messageId": "item-agent", + "content": { + "text": "lo", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + }, + { + "messageId": "", + "content": { + "text": "Something went wrong\n\n", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } +] diff --git a/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-tool-call-upsert.json b/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-tool-call-upsert.json new file mode 100644 index 000000000..a22c10be5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/tool-calls-and-messages-v2-tool-call-upsert.json @@ -0,0 +1,31 @@ +[ + { + "toolCallId": "item-read", + "title": "Read file '/workspace/src/a.ts'", + "kind": "read", + "status": "in_progress", + "locations": [ + { + "path": "/workspace/src/a.ts" + } + ], + "sessionUpdate": "tool_call_update" + }, + { + "toolCallId": "item-read", + "status": "completed", + "content": [ + { + "content": { + "text": "export {};\n", + "type": "text" + }, + "type": "content" + } + ], + "rawOutput": { + "exit_code": 0 + }, + "sessionUpdate": "tool_call_update" + } +] diff --git a/src/__tests__/CodexACPAgent/data/v2-resume-replay-review-and-hide.json b/src/__tests__/CodexACPAgent/data/v2-resume-replay-review-and-hide.json new file mode 100644 index 000000000..c29690ca9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/v2-resume-replay-review-and-hide.json @@ -0,0 +1,165 @@ +[ + { + "sessionId": "thread-1", + "update": { + "messageId": "reviewer-reasoning", + "content": [], + "sessionUpdate": "agent_thought" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "reviewer-reasoning", + "content": { + "text": "Reviewing the diff", + "type": "text" + }, + "sessionUpdate": "agent_thought_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review-entered", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review-entered", + "content": { + "text": "Entered review mode: Review the diff", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review-exited", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review-exited", + "content": { + "text": "Exited review mode: Looks good", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review-summary", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review-summary", + "content": { + "text": "Review complete.", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "ordinary-prompt", + "content": { + "text": "Reply with just OK.", + "type": "text" + }, + "sessionUpdate": "user_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "ordinary-reply", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "ordinary-reply", + "content": { + "text": "OK", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review2-entered", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review2-entered", + "content": { + "text": "Entered review mode: Second review", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review2-exited", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review2-exited", + "content": { + "text": "Exited review mode: All good", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review2-summary", + "content": [], + "sessionUpdate": "agent_message" + } + }, + { + "sessionId": "thread-1", + "update": { + "messageId": "review2-summary", + "content": { + "text": "Second review complete.", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } +] diff --git a/src/__tests__/CodexACPAgent/diff-v2.test.ts b/src/__tests__/CodexACPAgent/diff-v2.test.ts new file mode 100644 index 000000000..d7a6e93aa --- /dev/null +++ b/src/__tests__/CodexACPAgent/diff-v2.test.ts @@ -0,0 +1,190 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import * as path from 'node:path'; +import type {FileUpdateChange, ThreadItem} from '../../app-server/v2'; +import { + connectSession, + dump, + itemCompleted, + itemStarted, + type PromptSession, + settle, + turnCompleted, + turnStarted, + userMessageItem, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +/** The diffs come from Codex alone, so the files do not exist. */ +const root = "/workspace"; + +/** Starts a turn; the v1 prompt response only arrives when the turn ends, so it is returned unawaited. */ +async function startTurn(client: PromptSession, protocolVersion: 1 | 2): Promise<{response: Promise}> { + const response = client.sendPrompt([{type: "text", text: "Edit the files"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = protocolVersion === 2 + ? client.turnStartParams[0]!["clientUserMessageId"] as string + : null; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + if (protocolVersion === 2) { + await response; + } + return {response}; +} + +function fileChangeItem(id: string, changes: FileUpdateChange[]): ThreadItem { + return {type: "fileChange", id, changes, status: "inProgress"}; +} + +/** Runs one turn with a single file change and returns the tool call updates the client received. */ +async function runFileChange(item: ThreadItem, protocolVersion: 1 | 2 = 2): Promise { + const client = await connectSession(protocolVersion); + try { + const {response} = await startTurn(client, protocolVersion); + client.emit(itemStarted(item)); + client.emit(itemCompleted({...item, status: "completed"} as ThreadItem)); + client.emit(turnCompleted()); + await response; + await client.promptRunFinished(); + await settle(); + return client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => ["tool_call", "tool_call_update"].includes(update.sessionUpdate)); + } finally { + client.connection.close(); + } +} + +function file(name: string): string { + return path.join(root, name); +} + +function diffs(updates: unknown[]): acpV2.Diff[] { + return (updates as acpV2.ToolCallUpdate[]) + .flatMap(update => update.content ?? []) + .filter(content => content.type === "diff") + .map(content => content as acpV2.Diff); +} + +describe('file change diffs over ACP v2', () => { + afterEach(() => { + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('renders an edit as a modify change with a git patch', async () => { + const target = file("edit.ts"); + const updates = await runFileChange(fileChangeItem("item-edit", [{ + path: target, + kind: {type: "update", move_path: null}, + diff: "@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n", + }])); + + expect(diffs(updates)[0]!.changes).toEqual([{operation: "modify", path: target}]); + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v2-edit.json'); + }); + + it('renders an added file as an add change with a new-file patch', async () => { + const target = file("added.ts"); + const updates = await runFileChange(fileChangeItem("item-add", [{ + path: target, + kind: {type: "add"}, + diff: "export const a = 1;\nexport const b = 2;\n", + }])); + + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v2-add.json'); + }); + + it('renders a deleted file as a delete change with a deleted-file patch', async () => { + const target = file("deleted.ts"); + const updates = await runFileChange(fileChangeItem("item-delete", [{ + path: target, + kind: {type: "delete"}, + diff: "export const gone = true;", + }])); + + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v2-delete.json'); + }); + + it('renders a moved and edited file as a move change with a rename patch', async () => { + const source = file("old-name.ts"); + const destination = file("new-name.ts"); + const updates = await runFileChange(fileChangeItem("item-move", [{ + path: source, + kind: {type: "update", move_path: destination}, + diff: `@@ -1 +1 @@\n-old code line\n+new code line\n\n\nMoved to: ${destination}`, + }])); + + expect(diffs(updates)[0]!.changes).toEqual([{operation: "move", oldPath: source, path: destination}]); + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v2-move.json'); + }); + + it('renders a pure rename as a move change with a rename-only patch', async () => { + const source = file("renamed-old.ts"); + const destination = file("renamed-new.ts"); + const updates = await runFileChange(fileChangeItem("item-rename", [{ + path: source, + kind: {type: "update", move_path: destination}, + diff: "", + }])); + + expect(diffs(updates)[0]!.changes).toEqual([{operation: "move", oldPath: source, path: destination}]); + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v2-rename.json'); + }); + + it('renders an added empty file as an add change with a header-only patch', async () => { + const target = file("empty.ts"); + const updates = await runFileChange(fileChangeItem("item-add-empty", [{ + path: target, + kind: {type: "add"}, + diff: "", + }])); + + expect(diffs(updates)[0]!.changes).toEqual([{operation: "add", path: target}]); + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v2-add-empty.json'); + }); + + it('sends no diff for a change without a valid patch', async () => { + const updates = await runFileChange(fileChangeItem("item-malformed", [{ + path: file("malformed.ts"), + kind: {type: "update", move_path: null}, + diff: "not a diff", + }])); + + expect(diffs(updates)).toEqual([]); + }); + + it('renders one diff per file of a multi-file change', async () => { + const edited = file("multi-edit.ts"); + const added = file("multi-add.ts"); + const deleted = file("multi-delete.ts"); + const updates = await runFileChange(fileChangeItem("item-multi", [ + {path: edited, kind: {type: "update", move_path: null}, diff: "@@ -1 +1 @@\n-let x = 1;\n+let x = 2;\n"}, + {path: added, kind: {type: "add"}, diff: "new\n"}, + {path: deleted, kind: {type: "delete"}, diff: "old\n"}, + ])); + + expect(diffs(updates).map(diff => diff.changes)).toEqual([ + [{operation: "modify", path: edited}], + [{operation: "add", path: added}], + [{operation: "delete", path: deleted}], + ]); + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v2-multi.json'); + }); + + it('keeps the v1 oldText/newText diff shape on a v1 connection', async () => { + const source = file("v1-old-name.ts"); + const destination = file("v1-new-name.ts"); + const added = file("v1-added.ts"); + const updates = await runFileChange(fileChangeItem("item-v1", [ + { + path: source, + kind: {type: "update", move_path: destination}, + diff: "@@ -1 +1 @@\n-old code line\n+new code line\n", + }, + {path: added, kind: {type: "add"}, diff: "new\n"}, + ]), 1); + + await expect(dump(updates)).toMatchFileSnapshot('data/diff-v1-unchanged.json'); + }); +}); diff --git a/src/__tests__/CodexACPAgent/elicitation-v2.test.ts b/src/__tests__/CodexACPAgent/elicitation-v2.test.ts new file mode 100644 index 000000000..781bf5165 --- /dev/null +++ b/src/__tests__/CodexACPAgent/elicitation-v2.test.ts @@ -0,0 +1,349 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import type {McpServerElicitationRequestParams} from '../../app-server/v2'; +import { + sessionId, + turnId, + connectSession, + userMessageItem, + itemCompleted, + turnStarted, + turnCompleted, + turnFinished, + settle, + stateUpdates, + dump, + type PromptSession, + type TranscriptEntry, +} from './v2-prompt-harness'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +function deferred(): {promise: Promise; resolve: (value: T) => void} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} + +/** + * Starts a prompt and lets its turn actually start (`turn/started` emitted) before returning, so + * an MCP elicitation triggered against it finds a real turn running, and its trailing `running` + * fires once it settles. codex-acp's own MCP OAuth re-auth fires at session open, outside any + * turn; this helper is for the in-turn `mcpServer/elicitation/request` tests below. + */ +async function startRunningPrompt(client: PromptSession) { + client.setTurnStart(async () => ({ + turn: {id: turnId, items: [], itemsView: "notLoaded" as const, status: "inProgress" as const, error: null, startedAt: null, completedAt: null, durationMs: null}, + })); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + return { + response, + clientUserMessageId, + finishTurn: async () => { + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; + client.emit(turnCompleted()); + await client.promptRunFinished(); + }, + }; +} + +function oauthReauthParams(overrides: Partial = {}): McpServerElicitationRequestParams { + return { + threadId: sessionId, + turnId, + serverName: "auth-server", + mode: "url", + _meta: null, + message: "Please authorize access", + url: "https://example.com/authorize", + elicitationId: "elicit-123", + ...overrides, + }; +} + +describe('elicitation/create over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('sends the MCP OAuth re-auth URL elicitation on the real v2 send path and accepts it', async () => { + const client = await connectSession(2, { + clientCapabilities: {elicitation: {url: {}}}, + onElicitation: async () => ({action: "accept", content: null, _meta: {source: "client"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + const start = client.transcript.length; + + const response = await client.triggerApproval('mcpServer/elicitation/request', oauthReauthParams()); + + expect(response).toEqual({action: "accept", content: null, _meta: {source: "client"}}); + const transcript = client.transcript.slice(start); + const elicitationRequests = transcript.flatMap(entry => "elicitationRequest" in entry ? [entry.elicitationRequest] : []); + expect(elicitationRequests).toEqual([{ + sessionId, + mode: "url", + message: "Please authorize access", + url: "https://example.com/authorize", + elicitationId: "elicit-123", + _meta: null, + }]); + // The turn is running when the elicitation fires, so its trailing `running` closes the + // `requires_action` bracket once the client answers (mirrors `session/request_permission`). + expect(stateUpdates(transcript)).toEqual([{state: "requires_action"}, {state: "running"}]); + await expect(dump(transcript)).toMatchFileSnapshot('data/elicitation-v2-oauth-reauth-accepted.json'); + + // Codex reports the underlying MCP server request as resolved once the OAuth flow lands; + // a URL elicitation only completes then (see `CodexElicitationHandler`). + client.emit({method: "serverRequest/resolved", params: {threadId: sessionId, requestId: "request-1"}}); + await settle(); + expect(client.transcript.slice(start).at(-1)).toEqual({elicitationComplete: {elicitationId: "elicit-123"}}); + + await finishTurn(); + }); + + it('declines the MCP OAuth re-auth elicitation', async () => { + const client = await connectSession(2, { + clientCapabilities: {elicitation: {url: {}}}, + onElicitation: async () => ({action: "decline", _meta: null}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + + const response = await client.triggerApproval('mcpServer/elicitation/request', oauthReauthParams()); + + expect(response).toEqual({action: "decline", content: null, _meta: null}); + await finishTurn(); + }); + + it('cancels the MCP OAuth re-auth elicitation when the client dismisses it', async () => { + const client = await connectSession(2, { + clientCapabilities: {elicitation: {url: {}}}, + onElicitation: async () => ({action: "cancel"}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + + const response = await client.triggerApproval('mcpServer/elicitation/request', oauthReauthParams()); + + expect(response).toEqual({action: "cancel", content: null, _meta: null}); + await finishTurn(); + }); + + it('falls back to session/request_permission when the client has no url elicitation capability', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "selected", optionId: "accept"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + const start = client.transcript.length; + + const response = await client.triggerApproval('mcpServer/elicitation/request', oauthReauthParams()); + + expect(response).toEqual({action: "accept", content: null, _meta: null}); + const transcript = client.transcript.slice(start); + expect(transcript.some(entry => "elicitationRequest" in entry)).toBe(false); + expect(transcript.some(entry => "permissionRequest" in entry)).toBe(true); + + await finishTurn(); + }); + + it('sends the MCP OAuth re-auth elicitation at session open with no turn running, and no state_update', async () => { + const client = await connectSession(2, { + clientCapabilities: {elicitation: {url: {}}}, + mcpServers: [{type: "stdio", name: "docs", command: "/usr/bin/docs-mcp"}], + mcpStartup: {ready: [], failed: [{server: "docs", error: "unauthorized", failureReason: "reauthenticationRequired"}], cancelled: []}, + onElicitation: async () => ({action: "accept", content: null, _meta: null}), + codexResponses: { + "mcpServer/oauth/login": async () => ({authorizationUrl: "https://example.com/oauth/authorize"}), + }, + }); + closeClient = () => client.connection.close(); + + await vi.waitFor(() => expect(client.transcript.some(entry => "elicitationRequest" in entry)).toBe(true)); + + const elicitationRequests = client.transcript.flatMap(entry => "elicitationRequest" in entry ? [entry.elicitationRequest] : []); + expect(elicitationRequests).toEqual([expect.objectContaining({sessionId, mode: "url"})]); + // No turn (and no v2 prompt) is running at session open, so the client must stay `idle` + // rather than being told `requires_action` for foreground work that does not exist. + // `state_update`s sent before `available_commands_update` land in `setupUpdates`. + const allUpdates: TranscriptEntry[] = client.setupUpdates.map(update => ({sessionUpdate: update})); + allUpdates.push(...client.transcript); + expect(stateUpdates(allUpdates)).toEqual([]); + }); + + it('sends an idle-time Codex MCP elicitation (turnId: null) after a prompt has finished, with no state_update', async () => { + const client = await connectSession(2, { + clientCapabilities: {elicitation: {url: {}}}, + onElicitation: async () => ({action: "accept", content: null, _meta: {source: "client"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + await finishTurn(); + await settle(); + const start = client.transcript.length; + + const response = await client.triggerApproval('mcpServer/elicitation/request', oauthReauthParams({turnId: null})); + + expect(response).toEqual({action: "accept", content: null, _meta: {source: "client"}}); + const transcript = client.transcript.slice(start); + expect(transcript.some(entry => "elicitationRequest" in entry)).toBe(true); + // The session is idle (the prompt already finished), so this must send no state_update. + expect(stateUpdates(transcript)).toEqual([]); + }); + + it('falls back to session/request_permission for an idle-time Codex MCP elicitation (turnId: null), with no state_update', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "selected", optionId: "accept"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + await finishTurn(); + await settle(); + const start = client.transcript.length; + + const response = await client.triggerApproval('mcpServer/elicitation/request', oauthReauthParams({turnId: null})); + + expect(response).toEqual({action: "accept", content: null, _meta: null}); + const transcript = client.transcript.slice(start); + expect(transcript.some(entry => "permissionRequest" in entry)).toBe(true); + expect(stateUpdates(transcript)).toEqual([]); + }); + + it('aborts a pending MCP elicitation on session/cancel', async () => { + const elicitation = deferred(); + let capturedSignal: AbortSignal | undefined; + const client = await connectSession(2, { + clientCapabilities: {elicitation: {url: {}}}, + onElicitation: async (_request, signal) => { + capturedSignal = signal; + return elicitation.promise; + }, + }); + closeClient = () => client.connection.close(); + const {response, clientUserMessageId} = await startRunningPrompt(client); + const start = client.transcript.length; + + const elicitationPromise = client.triggerApproval('mcpServer/elicitation/request', oauthReauthParams()); + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + expect(capturedSignal!.aborted).toBe(false); + + await client.cancel(); + await vi.waitFor(() => expect(capturedSignal!.aborted).toBe(true)); + + // A conforming client answers `cancel` once it has seen `session/cancel`. + elicitation.resolve({action: "cancel"}); + expect(await elicitationPromise).toEqual({action: "cancel", content: null, _meta: null}); + + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + client.emit(turnFinished("interrupted")); + await response; + await client.promptRunFinished(); + await settle(); + + // The trailing extra `running` (before settling to `idle`) reflects a late elicitation + // answer arriving after cancellation; this mirrors the analogous permission-request test. + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([ + {state: "requires_action"}, + {state: "running"}, + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + ]); + }); + + /** + * `chat-gpt-device-code` login has no session, so it can't use the v2 prompt harness (which + * always opens one). This mirrors `auth-v2.test.ts`'s bespoke connection setup instead, adding + * elicitation wiring. + */ + async function connectAuthClient(onElicitation: (request: acpV2.CreateElicitationRequest, signal: AbortSignal) => acpV2.CreateElicitationResponse | Promise) { + const mocks = createMockConnections(); + const transcript: TranscriptEntry[] = []; + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string) => { + switch (method) { + case "account/read": + return {account: null, requiresOpenaiAuth: true}; + case "account/login/start": + return { + type: "chatgptDeviceCode", + loginId: "login-1", + verificationUrl: "https://auth.example.com/device", + userCode: "ABC-123", + }; + default: + return {}; + } + }); + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockResolvedValue({ready: [], failed: [], cancelled: []}); + const router = createAcpAgentRouter((connection) => new CodexAcpServer(connection, codexAcpClient)); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => checkV2SessionUpdate(ctx.params.update)) + .onRequest(acpV2.methods.client.elicitation.create, async (ctx) => { + transcript.push({elicitationRequest: ctx.params}); + return await onElicitation(ctx.params, ctx.signal); + }) + .onNotification(acpV2.methods.client.elicitation.complete, (ctx) => { + transcript.push({elicitationComplete: ctx.params}); + }) + .connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable)); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + capabilities: {elicitation: {url: {}}}, + }); + return {connection, transcript, mocks}; + } + + it('completes the chat-gpt-device-code login end to end via elicitation/create', async () => { + const {connection, transcript, mocks} = await connectAuthClient( + async () => ({action: "accept", content: null, _meta: null}), + ); + closeClient = () => connection.close(); + + const login = connection.agent.request(acpV2.methods.agent.auth.login, {methodId: "chat-gpt-device-code"} as acpV2.LoginAuthRequest); + + await vi.waitFor(() => expect(transcript.some(entry => "elicitationRequest" in entry)).toBe(true)); + const elicitationRequest = transcript.flatMap(entry => "elicitationRequest" in entry ? [entry.elicitationRequest] : []).at(-1)!; + expect(elicitationRequest).toEqual({ + requestId: expect.anything(), + mode: "url", + url: "https://auth.example.com/device", + message: "Sign in to ChatGPT and enter this code: ABC-123", + elicitationId: "login-1", + }); + // A request-scoped elicitation (no session exists yet) must not send `state_update`. + expect(stateUpdates(transcript)).toEqual([]); + + mocks.notificationHandlers.get("account/login/completed")?.({ + loginId: "login-1", success: true, error: null, onboardingEntrypoint: null, + }); + + await expect(login).resolves.toEqual({}); + await settle(); + expect(transcript.some(entry => "elicitationComplete" in entry + && entry.elicitationComplete.elicitationId === "login-1")).toBe(true); + }); +}); diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index db67f7cd9..ba7f5d74f 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -12,7 +12,7 @@ import {AgentMode} from "../../AgentMode"; import {DIFF_PATCH_MAX_BYTES} from '../../GitPatch'; async function createFileChangeUpdate(item: ThreadItem & {type: 'fileChange'}, diffPatch = false) { - return new AcpToolCallRenderer(ClientCapabilities.DEFAULT).render(FileChangeReporter.started(item, diffPatch)); + return new AcpToolCallRenderer(ClientCapabilities.DEFAULT).render(FileChangeReporter.started(item, diffPatch ? "air" : "none")); } const { mockFiles, mockReadDelays, mockFileContent, delayMockFileRead, removeMockFile, clearMockFiles } = vi.hoisted(() => { diff --git a/src/__tests__/CodexACPAgent/initialize-v2.test.ts b/src/__tests__/CodexACPAgent/initialize-v2.test.ts new file mode 100644 index 000000000..265f83d9c --- /dev/null +++ b/src/__tests__/CodexACPAgent/initialize-v2.test.ts @@ -0,0 +1,165 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {z} from 'zod'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {toV1ClientCapabilitiesView} from '../../AcpV2ClientCapabilities'; +import {AIR_NATIVE_SUBAGENT_SESSIONS_KEY, clientSupportsAirCapability} from '../../AirExtension'; +import {AcpV2Connection} from '../../ACPSessionConnection'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import {clientSupportsFormElicitation, clientSupportsUrlElicitation} from '../../ElicitationCapabilities'; +import {ClientCapabilities} from '../../tool-calls/ClientCapabilities'; +import {clientSupportsCompaction} from '../../CodexSessionCompactions'; +import {clientSupportsNotices} from '../../SessionNotice'; +import {createMockConnections} from './test-utils'; + +const v2ClientCapabilities: acpV2.ClientCapabilities = { + auth: { + terminal: {}, + _meta: {gateway: true}, + }, + elicitation: {form: {}, url: {}}, + _meta: { + jetbrains: { + air: { + version: 1, + capabilities: [AIR_NATIVE_SUBAGENT_SESSIONS_KEY], + }, + }, + }, +}; + +function connectRouter() { + const mocks = createMockConnections(); + const router = createAcpAgentRouter((connection) => { + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: true}); + return new CodexAcpServer(connection, codexAcpClient); + }); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + return { + mocks, + clientStream: acp.ndJsonStream(clientToAgent.writable, agentToClient.readable), + }; +} + +/** The agent version changes on every release; keep snapshots stable. */ +function withStableVersion(response: T, infoKey: "info" | "agentInfo"): T { + const info = (response as Record)[infoKey]; + return {...response, [infoKey]: {...info, version: ""}}; +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +describe('CodexACPAgent - initialize over ACP v2', () => { + let closeClient: (() => void) | null = null; + + beforeEach(() => { + closeClient = null; + }); + + afterEach(() => { + closeClient?.(); + vi.clearAllMocks(); + }); + + it('routes a v2 initialize to the v2 chain and returns the v2 capability shape', async () => { + const {clientStream} = connectRouter(); + const authStatusUpdates: unknown[] = []; + const connection = acpV2.client({name: "test-client"}) + .onNotification("_auth/status_update", z.object({}).passthrough(), (ctx) => { + authStatusUpdates.push(ctx.params); + }) + .connect(clientStream); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + capabilities: v2ClientCapabilities, + }); + + await expect(dump(withStableVersion(response, "info"))).toMatchFileSnapshot( + 'data/initialize-v2-response.json' + ); + // Extension notifications still reach a v2 client before any v2 send path exists. + await vi.waitFor(() => expect(authStatusUpdates).toHaveLength(1)); + }); + + it('advertises only the auth methods the v2 client capabilities allow', async () => { + const {clientStream} = connectRouter(); + const connection = acpV2.client({name: "test-client"}).connect(clientStream); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + }); + + const methodIds = response.authMethods?.map((method) => method.methodId); + expect(methodIds).not.toContain("gateway"); + expect(methodIds).not.toContain("chat-gpt-device-code"); + }); + + it('still routes a v1 initialize to the unchanged v1 chain', async () => { + const {clientStream} = connectRouter(); + const connection = acp.client({name: "test-client"}).connect(clientStream); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {auth: {_meta: {gateway: true}}}, + }); + + await expect(dump(withStableVersion(response, "agentInfo"))).toMatchFileSnapshot( + 'data/initialize-v1-routed-response.json' + ); + }); +}); + +describe('ACP v2 client capability normalization', () => { + it('keeps the fields existing readers probe at the same relative path', async () => { + const view = toV1ClientCapabilitiesView(v2ClientCapabilities); + + await expect(dump(view)).toMatchFileSnapshot('data/initialize-v2-client-capabilities-view.json'); + expect(clientSupportsFormElicitation(view)).toBe(true); + expect(clientSupportsUrlElicitation(view)).toBe(true); + expect(view?.auth?._meta?.["gateway"]).toBe(true); + expect(clientSupportsAirCapability(view, AIR_NATIVE_SUBAGENT_SESSIONS_KEY)).toBe(true); + }); + + it('reports only the capability-free v2 features when the v2 client sends no capabilities', () => { + const view = toV1ClientCapabilitiesView(undefined); + + expect(ClientCapabilities.from(view).planUpdates).toBe(true); + expect(clientSupportsCompaction(view)).toBe(true); + expect(clientSupportsNotices(view)).toBe(true); + expect(clientSupportsFormElicitation(view)).toBe(false); + expect(clientSupportsUrlElicitation(view)).toBe(false); + expect(clientSupportsAirCapability(view, AIR_NATIVE_SUBAGENT_SESSIONS_KEY)).toBe(false); + }); +}); + +describe('ACP v2 connection before the v2 send path exists for other standard methods', () => { + it('forwards extension methods and rejects v1-shaped standard methods without a v2 send path', async () => { + const client = {notify: vi.fn().mockResolvedValue(undefined), request: vi.fn().mockResolvedValue({})}; + const view = new AcpV2Connection(client as any).extensionOnlyV1View(); + + await view.notify("_auth/status_update", {authStatus: {kind: "none"}}); + // `session/request_permission` now has a v2 send path (see permissions-v2.test.ts); + // other standard methods without one yet still reject. + await expect(view.request(acp.methods.client.fs.readTextFile, { + sessionId: "session", + path: "/workspace/a.ts", + })).rejects.toThrow("'fs/read_text_file' is not supported on an ACP v2 connection yet"); + + expect(client.notify.mock.calls).toEqual([["_auth/status_update", {authStatus: {kind: "none"}}]]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/mcp-config-v2.test.ts b/src/__tests__/CodexACPAgent/mcp-config-v2.test.ts new file mode 100644 index 000000000..44616bda5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/mcp-config-v2.test.ts @@ -0,0 +1,96 @@ +import {describe, expect, it, vi} from 'vitest'; +import type * as acp from '@agentclientprotocol/sdk'; +import type * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createCodexMockTestFixture, createTestModel} from '../acp-test-utils'; +import type {AcpMcpServer} from '../../McpServerConfig'; + +/** + * Starts a session with the given MCP servers and returns the `mcp_servers` config + * sent to Codex `thread/start`. + */ +async function startSessionWithMcpServers(mcpServers: Array | undefined) { + const mockFixture = createCodexMockTestFixture(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAppServerClient, "configRead").mockResolvedValue({config: {}, origins: {}, layers: []} as any); + const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({ + thread: {id: "thread-id"} as any, + model: "gpt-5", + reasoningEffort: "medium", + serviceTier: null, + } as any); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + await codexAcpClient.newSession({ + cwd: "/workspace", + ...(mcpServers !== undefined && {mcpServers}), + }); + const config = threadStartSpy.mock.calls[0]![0].config; + return config?.["mcp_servers"] ?? null; +} + +describe('MCP server config from ACP v1 and v2 session requests', () => { + it('converts v2 stdio and http servers, defaulting omitted optional fields', async () => { + const v2Servers: Array = [ + { + type: "stdio", + name: "stdio-full", + command: "/usr/local/bin/mcp-fs", + args: ["--root", "/workspace"], + env: [{name: "EXAMPLE", value: "1"}], + }, + {type: "stdio", name: "stdio-minimal", command: "/usr/local/bin/mcp-minimal"}, + { + type: "http", + name: "http-full", + url: "https://example.com/mcp", + headers: [{name: "Authorization", value: "Bearer token"}], + }, + {type: "http", name: "http-minimal", url: "https://example.com/minimal"}, + ]; + + await expect(JSON.stringify(await startSessionWithMcpServers(v2Servers), null, 2)) + .toMatchFileSnapshot("data/mcp-config-v2-servers.json"); + }); + + it('converts an untagged v1 stdio server and a v1 http server', async () => { + const v1Servers: Array = [ + {name: "stdio-v1", command: "npx", args: ["server"], env: [{name: "EXAMPLE", value: "1"}]}, + { + type: "http", + name: "http-v1", + url: "https://example.com/mcp", + headers: [{name: "Authorization", value: "Bearer token"}], + }, + ]; + + await expect(JSON.stringify(await startSessionWithMcpServers(v1Servers), null, 2)) + .toMatchFileSnapshot("data/mcp-config-v1-servers.json"); + }); + + it('adds no MCP config when mcpServers is omitted', async () => { + expect(await startSessionWithMcpServers(undefined)).toBeNull(); + }); + + it('rejects sse, acp, and custom transports', async () => { + const unsupported: Record = { + "v2 sse": {type: "sse", name: "sse", url: "https://example.com/sse"}, + "v2 custom": {type: "_custom", name: "custom", endpoint: "somewhere"}, + "v1 sse": {type: "sse", name: "sse", url: "https://example.com/sse", headers: []} satisfies acp.McpServer, + "v1 acp": {type: "acp", name: "acp", id: "server-1"}, + }; + const rejections: Record = {}; + for (const [label, server] of Object.entries(unsupported)) { + const error = await startSessionWithMcpServers([server as AcpMcpServer]).then(() => null, err => err); + rejections[label] = error && {code: error.code, message: error.message, data: error.data}; + } + + await expect(JSON.stringify(rejections, null, 2)) + .toMatchFileSnapshot("data/mcp-config-unsupported-transports.json"); + }); +}); diff --git a/src/__tests__/CodexACPAgent/omitted-params-v2.test.ts b/src/__tests__/CodexACPAgent/omitted-params-v2.test.ts new file mode 100644 index 000000000..0736bb8a0 --- /dev/null +++ b/src/__tests__/CodexACPAgent/omitted-params-v2.test.ts @@ -0,0 +1,164 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import type {AnyWireMessage} from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import {createMockConnections} from './test-utils'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +/** + * Raw JSON-RPC connection to the router. Unlike the typed `acpV2.client(...)` wrapper (which + * always sends a `params` object), this writes wire items exactly as given, so tests can send a + * request with `params` entirely absent. + */ +function connectRaw() { + const mocks = createMockConnections(); + // `account/read` reports an already-authenticated API-key account, so `session/list` and + // `auth/logout` don't hit the auth-required path. + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string) => { + switch (method) { + case "account/read": + return {account: {type: "apiKey"}, requiresOpenaiAuth: true}; + case "thread/list": + return {data: [], nextCursor: null}; + case "account/logout": + // `logout()` awaits this notification before resolving. + setImmediate(() => mocks.notificationHandlers.get("account/updated")?.({authMode: null, planType: null})); + return {}; + default: + return {}; + } + }); + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + const router = createAcpAgentRouter((connection) => new CodexAcpServer(connection, codexAcpClient)); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acpV2.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + const clientStream = acpV2.ndJsonStream(clientToAgent.writable, agentToClient.readable); + const writer = clientStream.writable.getWriter(); + const reader = clientStream.readable.getReader(); + return { + send: (message: AnyWireMessage) => writer.write(message), + /** Reads wire items until a response (or batch of responses) arrives, skipping notifications. */ + nextResponse: async (): Promise => { + while (true) { + const {value, done} = await reader.read(); + if (done) throw new Error("stream closed before a response arrived"); + const isResponse = Array.isArray(value) + ? value.every((item) => "id" in item && ("result" in item || "error" in item)) + : "id" in value && ("result" in value || "error" in value); + if (isResponse) return value; + } + }, + close: () => { + void writer.close().catch(() => {}); + }, + }; +} + +async function initializeV2(raw: ReturnType) { + await raw.send({ + jsonrpc: "2.0", + id: "init", + method: acpV2.methods.agent.initialize, + params: {protocolVersion: 2, info: {name: "test-client", version: "1.0.0"}}, + }); + await raw.nextResponse(); +} + +async function initializeV1(raw: ReturnType) { + await raw.send({ + jsonrpc: "2.0", + id: "init", + method: acp.methods.agent.initialize, + params: {protocolVersion: 1}, + }); + await raw.nextResponse(); +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** Keeps only the fields that stay stable across SDK/zod versions. */ +function errorShape(response: AnyWireMessage): unknown { + const error = (response as {error?: {code: number}}).error; + return error ? {code: error.code} : undefined; +} + +describe('v2 requests with omitted params', () => { + afterEach(() => { + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('accepts session/list with no params member', async () => { + const raw = connectRaw(); + await initializeV2(raw); + + await raw.send({jsonrpc: "2.0", id: 1, method: acpV2.methods.agent.session.list}); + const response = await raw.nextResponse(); + + await expect(dump(response)).toMatchFileSnapshot('data/omitted-params-v2-session-list.json'); + }); + + it('accepts auth/logout with no params member', async () => { + const raw = connectRaw(); + await initializeV2(raw); + + await raw.send({jsonrpc: "2.0", id: 1, method: acpV2.methods.agent.auth.logout}); + const response = await raw.nextResponse(); + + await expect(dump(response)).toMatchFileSnapshot('data/omitted-params-v2-auth-logout.json'); + }); + + it('fixes a batch entry that has no params member', async () => { + const raw = connectRaw(); + await initializeV2(raw); + + await raw.send([ + {jsonrpc: "2.0", id: "a", method: acpV2.methods.agent.session.list}, + {jsonrpc: "2.0", id: "b", method: acpV2.methods.agent.session.list, params: {}}, + ] as unknown as AnyWireMessage); + const response = await raw.nextResponse(); + + expect(Array.isArray(response)).toBe(true); + const errors = (response as unknown as Array<{id: unknown, error?: unknown}>) + .map((entry) => ({id: entry.id, error: errorShape(entry as unknown as AnyWireMessage)})); + expect(errors).toEqual([{id: "a", error: undefined}, {id: "b", error: undefined}]); + }); + + it('leaves params: null untouched (still rejected today)', async () => { + const raw = connectRaw(); + await initializeV2(raw); + + await raw.send({jsonrpc: "2.0", id: 1, method: acpV2.methods.agent.session.list, params: null}); + const response = await raw.nextResponse(); + + expect(errorShape(response)).toEqual({code: -32602}); + }); + + it('still rejects session/new with no params (control: the fix is scoped to session/list and auth/logout)', async () => { + const raw = connectRaw(); + await initializeV2(raw); + + await raw.send({jsonrpc: "2.0", id: 1, method: acpV2.methods.agent.session.new}); + const response = await raw.nextResponse(); + + expect(errorShape(response)).toEqual({code: -32602}); + }); + + it('v1 session/list with no params is unchanged (still rejected)', async () => { + const raw = connectRaw(); + await initializeV1(raw); + + await raw.send({jsonrpc: "2.0", id: 1, method: acp.methods.agent.session.list}); + const response = await raw.nextResponse(); + + expect(errorShape(response)).toEqual({code: -32602}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/permissions-v2.test.ts b/src/__tests__/CodexACPAgent/permissions-v2.test.ts new file mode 100644 index 000000000..11f30bfde --- /dev/null +++ b/src/__tests__/CodexACPAgent/permissions-v2.test.ts @@ -0,0 +1,281 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {CommandExecutionApprovalRequest, FileChangeApprovalRequest} from '../../CodexAppServerClient'; +import type { + CommandExecutionRequestApprovalParams, + FileChangeRequestApprovalParams, +} from '../../app-server/v2'; +import { + sessionId, + turnId, + cwd, + connectSession, + userMessageItem, + itemCompleted, + turnStarted, + turnCompleted, + settle, + stateUpdates, + indexOf, + isState, + dump, + type PromptSession, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +/** + * Starts a prompt and lets its turn actually start (`turn/started` emitted) before returning, so + * a permission request triggered against it finds a real turn running -- matching how command/ + * file-change approvals are only ever raised mid-turn -- and its trailing `running` fires once + * the request settles. + */ +async function startRunningPrompt(client: PromptSession) { + client.setTurnStart(async () => ({ + turn: {id: turnId, items: [], itemsView: "notLoaded" as const, status: "inProgress" as const, error: null, startedAt: null, completedAt: null, durationMs: null}, + })); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + return { + response, + finishTurn: async () => { + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; + client.emit(turnCompleted()); + await client.promptRunFinished(); + }, + }; +} + +function commandApprovalParams(overrides: Partial = {}): CommandExecutionRequestApprovalParams { + return { + kind: "command", + threadId: sessionId, + turnId, + itemId: "cmd-item-1", + startedAtMs: 0, + environmentId: null, + command: "ls -la", + cwd, + ...overrides, + }; +} + +function fileChangeApprovalParams(overrides: Partial = {}): FileChangeRequestApprovalParams { + return { + threadId: sessionId, + turnId, + itemId: "edit-item-1", + startedAtMs: 0, + ...overrides, + }; +} + +describe('session/request_permission over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('sends a command approval request in the v2 shape and applies the allowed decision', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "selected", optionId: "allow_once"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + const start = client.transcript.length; + + const response = await client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams()); + + expect(response).toEqual({decision: "accept"}); + const transcript = client.transcript.slice(start); + // The turn is running when the approval fires, so its trailing `running` closes the + // `requires_action` bracket once the client answers. + expect(stateUpdates(transcript)).toEqual([{state: "requires_action"}, {state: "running"}]); + const requiresAction = indexOf(transcript, isState("requires_action")); + const permission = indexOf(transcript, entry => "permissionRequest" in entry); + expect(requiresAction).toBeLessThan(permission); + await expect(dump(transcript)).toMatchFileSnapshot('data/permissions-v2-command-allowed.json'); + + await finishTurn(); + }); + + it('keeps the AIR permission presentation for an AIR client and sends no private key', async () => { + const requests: acpV2.RequestPermissionRequest[] = []; + const client = await connectSession(2, { + clientCapabilities: {_meta: {jetbrains: {air: {version: 1}}}}, + onRequestPermission: async (request) => { + requests.push(request); + return {outcome: {outcome: "selected", optionId: "allow_once"}}; + }, + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + + await client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams({reason: "Needs a listing"})); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({title: "Run command?", description: "Needs a listing"}); + expect(requests[0]!._meta).toEqual({ + jetbrains: {air: {version: 1, permission: {version: 1, title: "Run command?", description: "Needs a listing"}}}, + }); + + await finishTurn(); + }); + + it('sends a command approval request and applies the rejected decision', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "selected", optionId: "decline"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + const start = client.transcript.length; + + const response = await client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams()); + + expect(response).toEqual({decision: "decline"}); + const transcript = client.transcript.slice(start); + // The turn is running when the approval fires (see the allowed test). + expect(stateUpdates(transcript)).toEqual([{state: "requires_action"}, {state: "running"}]); + await expect(dump(transcript)).toMatchFileSnapshot('data/permissions-v2-command-rejected.json'); + + await finishTurn(); + }); + + it('sends a file-change approval request in the v2 shape and applies the selected decision', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "selected", optionId: "allow_once"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + const start = client.transcript.length; + + const response = await client.triggerApproval(FileChangeApprovalRequest.method, fileChangeApprovalParams()); + + expect(response).toEqual({decision: "accept"}); + const transcript = client.transcript.slice(start); + // The turn is running when the approval fires (see the allowed test). + expect(stateUpdates(transcript)).toEqual([{state: "requires_action"}, {state: "running"}]); + await expect(dump(transcript)).toMatchFileSnapshot('data/permissions-v2-file-change-allowed.json'); + + await finishTurn(); + }); + + it('cancels the decision when the client returns a cancelled outcome', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "cancelled"}}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + const start = client.transcript.length; + + const response = await client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams()); + + expect(response).toEqual({decision: "cancel"}); + const transcript = client.transcript.slice(start); + // The turn is running when the approval fires, even though the request was cancelled + // (see the allowed test). + expect(stateUpdates(transcript)).toEqual([{state: "requires_action"}, {state: "running"}]); + + await finishTurn(); + }); + + it('cancels the decision when the client answers with an unknown v2 outcome (ACP-ENUM-203)', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "some_future_outcome"} as unknown as acpV2.RequestPermissionOutcome}), + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + + const response = await client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams()); + + expect(response).toEqual({decision: "cancel"}); + await finishTurn(); + }); + + it('cancels the decision and still resumes `running` when the client request errors', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => { + throw new Error("client failed to answer"); + }, + }); + closeClient = () => client.connection.close(); + const {finishTurn} = await startRunningPrompt(client); + const start = client.transcript.length; + + const response = await client.triggerApproval(CommandExecutionApprovalRequest.method, commandApprovalParams()); + + expect(response).toEqual({decision: "cancel"}); + // The turn is running when the approval fires, so `running` resumes even though the + // request itself failed (see the allowed test). + expect(stateUpdates(client.transcript.slice(start))).toEqual([{state: "requires_action"}, {state: "running"}]); + + await finishTurn(); + }); + + it('sends the plan-implementation permission request on the real v2 send path and honors approval', async () => { + const client = await connectSession(2, { + onRequestPermission: async () => ({outcome: {outcome: "selected", optionId: "implement_plan"}}), + }); + closeClient = () => client.connection.close(); + + // Switch into plan mode first; `/plan` is a local command and starts no turn. + await client.sendPrompt([{type: "text", text: "/plan"}]); + await client.promptRunFinished(0); + await settle(); + expect(client.turnStartParams).toEqual([]); + const start = client.transcript.length; + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + const {messageId} = await response; + client.emit(itemCompleted({type: "plan", id: "plan-item", text: "1. Do the change."})); + client.emit(turnCompleted()); + + // Approval is granted over the real v2 wire, so codex-acp starts the "Implement the + // approved plan." turn itself, still inside this same prompt. + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + const implementationId = client.turnStartParams[1]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(implementationId, "Implement the approved plan."), "turn-2")); + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + await settle(); + + const transcript = client.transcript.slice(start); + const permissionRequests = transcript.flatMap(entry => "permissionRequest" in entry ? [entry.permissionRequest] : []); + expect(permissionRequests).toEqual([expect.objectContaining({ + sessionId, + title: "Implement this plan?", + subject: { + type: "tool_call", + toolCall: expect.objectContaining({kind: "switch_mode", status: "pending"}), + }, + options: [ + {optionId: "implement_plan", name: "Yes, implement this plan", kind: "allow_once"}, + {optionId: "revise_plan", name: "No, and tell Codex what to do differently", kind: "reject_once"}, + ], + })]); + // The prompt's own `running` (from insertion) brackets the whole exchange. The plan + // permission request fires after the proposal's turn already completed and before the + // implementation turn starts, so no Codex turn is running while it's pending -- but the + // v2 prompt itself is still in flight, so the session is still busy and the trailing + // `running` still closes the `requires_action` bracket once the client approves. + expect(stateUpdates(transcript)).toEqual([ + {state: "running"}, + {state: "requires_action"}, + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + await expect(dump(transcript, messageId).replaceAll(implementationId, "")) + .toMatchFileSnapshot('data/permissions-v2-plan-implementation.json'); + }); +}); diff --git a/src/__tests__/CodexACPAgent/plan-review-events.test.ts b/src/__tests__/CodexACPAgent/plan-review-events.test.ts index a5b3ab0e7..9d7e79cc5 100644 --- a/src/__tests__/CodexACPAgent/plan-review-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-review-events.test.ts @@ -242,6 +242,35 @@ describe("CodexACPAgent - plan review", () => { expect(sessionState.collaborationMode).toBe(PLAN_COLLABORATION_MODE); }); + it("ends the prompt as cancelled, not end_turn, when session/cancel arrives during the plan review", async () => { + const permission = deferred(); + const {promptPromise, sessionState, turnStart} = await startPlanPrompt(null, { + permissionResponse: permission.promise, + }); + await vi.waitFor(() => { + expect(fixture.getAcpConnectionEvents([])).toContainEqual({ + method: "requestPermission", + args: [expect.objectContaining({sessionId})], + }); + }); + + // `cancel()` looks the session up in the live `sessions` map, not through the + // `getSessionState` mock `startPlanPrompt` relies on. + // @ts-expect-error - registering local session state for the cancel path + fixture.getCodexAcpAgent().sessions.set(sessionId, sessionState); + // The plan turn has already completed, so Codex rejects `turn/interrupt` for its stale + // turn id; codex-acp must still end the prompt as cancelled via `cancelRequested`. + const turnInterrupt = vi.spyOn(fixture.getCodexAcpClient(), "turnInterrupt") + .mockRejectedValue(new Error("no such turn")); + + await fixture.getCodexAcpAgent().cancel({sessionId}); + expect(turnInterrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "plan-turn"}); + + permission.resolve({outcome: {outcome: "cancelled"}}); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + expect(turnStart).toHaveBeenCalledTimes(1); + }); + it("routes a terminal error during plan approval as a session-scoped failure", async () => { const permission = deferred(); const {promptPromise, sessionState, turnStart} = await startPlanPrompt(null, { diff --git a/src/__tests__/CodexACPAgent/plans-and-probes-v2.test.ts b/src/__tests__/CodexACPAgent/plans-and-probes-v2.test.ts new file mode 100644 index 000000000..dd8bb296d --- /dev/null +++ b/src/__tests__/CodexACPAgent/plans-and-probes-v2.test.ts @@ -0,0 +1,181 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {ACPSessionConnection, AcpV2Connection} from '../../ACPSessionConnection'; +import {toV2SessionUpdate} from '../../AcpV2SessionUpdate'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import type {ServerNotification} from '../../app-server'; +import type {Turn} from '../../app-server/v2'; +import {ClientCapabilities} from '../../tool-calls/ClientCapabilities'; +import {createTestSessionState} from '../acp-test-utils'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +const sessionId = "session-1"; +const turnId = "turn-1"; + +const availableCommandsUpdate: acp.SessionUpdate = { + sessionUpdate: "available_commands_update", + availableCommands: [ + {name: "review", description: "Review changes", input: {hint: "focus"}, _meta: {kind: "hint"}}, + {name: "status", description: "Show status", input: null}, + {name: "compact", description: "Compact the thread"}, + ], +}; + +function createTurn(status: Turn["status"]): Turn { + return {id: turnId, items: [], itemsView: "full", status, error: null, startedAt: null, completedAt: null, durationMs: null}; +} + +/** + * Connects a v2 client that declares no capabilities. Sessions can't be created or prompted + * over v2 yet, so the test registers session state and runs a prompt on the agent directly; + * its updates still go out through the v2 connection. + */ +async function connectV2Client() { + const mocks = createMockConnections(); + const appServer = new CodexAppServerClient(mocks.mockCodexConnection as any); + const codexAcpClient = new CodexAcpClient(appServer); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: true}); + vi.spyOn(appServer, "turnStart").mockResolvedValue({turn: createTurn("inProgress")}); + vi.spyOn(appServer, "awaitTurnCompleted").mockResolvedValue({threadId: sessionId, turn: createTurn("completed")}); + let agent: CodexAcpServer | null = null; + let agentConnection: unknown = null; + const router = createAcpAgentRouter((connection) => { + agentConnection = connection; + agent = new CodexAcpServer(connection, codexAcpClient); + return agent; + }); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + const updates: acpV2.UpdateSessionNotification[] = []; + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => { + checkV2SessionUpdate(ctx.params.update); + updates.push(ctx.params); + }) + .connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable)); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + }); + if (!(agentConnection instanceof AcpV2Connection)) { + throw new Error("expected the router to hand the agent a v2 connection"); + } + const view = agentConnection.extensionOnlyV1View(); + + async function startPrompt() { + vi.spyOn(agent!, "getSessionState").mockReturnValue(createTestSessionState({ + sessionId, + clientCapabilities: ClientCapabilities.fromV2(undefined), + })); + await agent!.prompt({sessionId, prompt: [{type: "text", text: "Continue."}]}); + updates.splice(0); + } + + async function send(notifications: ServerNotification[]) { + for (const notification of notifications) { + mocks.getUnhandledNotificationHandler()!(notification); + } + await codexAcpClient.waitForSessionNotifications(sessionId); + } + + return {connection, updates, view, startPrompt, send}; +} + +function planUpdated(steps: {step: string, status: "pending" | "inProgress" | "completed"}[]): ServerNotification { + return {method: "turn/plan/updated", params: {threadId: sessionId, turnId, explanation: null, plan: steps}}; +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +describe('Plans, compaction, notices and commands over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('sends the structured plan as an items plan_update with a stable planId', async () => { + const {connection, updates, startPrompt, send} = await connectV2Client(); + closeClient = () => connection.close(); + await startPrompt(); + + await send([ + planUpdated([{step: "Add the mapping", status: "inProgress"}, {step: "Verify it", status: "pending"}]), + planUpdated([{step: "Add the mapping", status: "completed"}, {step: "Verify it", status: "inProgress"}]), + ]); + + await vi.waitFor(() => expect(updates).toHaveLength(2)); + const planIds = updates.map(({update}) => update.sessionUpdate === "plan_update" + ? (update as acpV2.PlanUpdate).plan.planId + : null); + expect(planIds[0]).toEqual(expect.any(String)); + expect(planIds[1]).toBe(planIds[0]); + await expect(dump(updates)).toMatchFileSnapshot('data/plans-v2-structured.json'); + }); + + it('sends the narrative plan as a markdown plan_update without a plan capability', async () => { + const {connection, updates, startPrompt, send} = await connectV2Client(); + closeClient = () => connection.close(); + await startPrompt(); + + const item = {type: "plan" as const, id: "plan-item-1", text: ""}; + await send([ + {method: "item/started", params: {threadId: sessionId, turnId, startedAtMs: 0, item}}, + {method: "item/plan/delta", params: {threadId: sessionId, turnId, itemId: item.id, delta: "1. Add the mapping."}}, + {method: "item/completed", params: {threadId: sessionId, turnId, completedAtMs: 1, item}}, + ]); + + await vi.waitFor(() => expect(updates).toHaveLength(1)); + await expect(dump(updates)).toMatchFileSnapshot('data/plans-v2-markdown.json'); + }); + + it('sends compaction and notice updates without client capabilities', async () => { + const {connection, updates, startPrompt, send} = await connectV2Client(); + closeClient = () => connection.close(); + await startPrompt(); + + const item = {type: "contextCompaction" as const, id: "compaction-1"}; + await send([ + {method: "item/started", params: {threadId: sessionId, turnId, startedAtMs: 0, item}}, + {method: "item/completed", params: {threadId: sessionId, turnId, completedAtMs: 1, item}}, + {method: "warning", params: {threadId: sessionId, message: "Optional integration unavailable"}}, + ]); + + await vi.waitFor(() => expect(updates).toHaveLength(3)); + await expect(dump(updates)).toMatchFileSnapshot('data/plans-v2-compaction-and-notice.json'); + }); + + it('tags command input as text so a v2 client keeps it', async () => { + const {connection, updates, view} = await connectV2Client(); + closeClient = () => connection.close(); + + await new ACPSessionConnection(view, sessionId).update(availableCommandsUpdate); + + await vi.waitFor(() => expect(updates).toHaveLength(1)); + expect(updates[0]!.update.sessionUpdate).toBe("available_commands_update"); + const {availableCommands} = updates[0]!.update as acpV2.AvailableCommandsUpdate; + // A v2 client's parser drops an untagged input, so the tag must arrive. + expect(availableCommands.map((command) => command.input)).toEqual([ + {type: "text", hint: "focus"}, + null, + undefined, + ]); + expect("input" in availableCommands[2]!).toBe(false); + await expect(dump(updates)).toMatchFileSnapshot('data/available-commands-v2-received.json'); + await expect(dump(toV2SessionUpdate(availableCommandsUpdate))) + .toMatchFileSnapshot('data/available-commands-v2-rendered.json'); + }); +}); diff --git a/src/__tests__/CodexACPAgent/pre-prompt-approvals-v1.test.ts b/src/__tests__/CodexACPAgent/pre-prompt-approvals-v1.test.ts new file mode 100644 index 000000000..ebd312e25 --- /dev/null +++ b/src/__tests__/CodexACPAgent/pre-prompt-approvals-v1.test.ts @@ -0,0 +1,103 @@ +import {afterEach, describe, expect, it} from 'vitest'; +import {CommandExecutionApprovalRequest, FileChangeApprovalRequest, PermissionsApprovalRequest} from '../../CodexAppServerClient'; +import {connectSession, sessionId, turnId, cwd, type PromptSession} from './v2-prompt-harness'; + +/** + * `installSessionState()` registers a baseline Codex-turn tracker (with deny-all approval/ + * elicitation handlers) for every session, on ACP v1 too, as soon as `session/new` completes -- + * before any `session/prompt` has run. Pin that a Codex approval/elicitation/user-input request + * for such a session still gets exactly the same reply app-server itself defaults to for a + * thread with no handler registered, so v1's pre-prompt wire behavior is unchanged. + */ +describe('pre-prompt Codex approval/elicitation requests on ACP v1', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + }); + + async function connectV1(): Promise { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + return client; + } + + it('declines a command execution approval with no prompt running', async () => { + const client = await connectV1(); + + const response = await client.triggerApproval(CommandExecutionApprovalRequest.method, { + kind: "command", + threadId: sessionId, + turnId, + itemId: "cmd-item-1", + startedAtMs: 0, + environmentId: null, + command: "ls -la", + cwd, + }); + + expect(response).toEqual({decision: "cancel"}); + }); + + it('declines a file-change approval with no prompt running', async () => { + const client = await connectV1(); + + const response = await client.triggerApproval(FileChangeApprovalRequest.method, { + threadId: sessionId, + turnId, + itemId: "edit-item-1", + startedAtMs: 0, + }); + + expect(response).toEqual({decision: "cancel"}); + }); + + it('grants no permissions for a permissions-request approval with no prompt running', async () => { + const client = await connectV1(); + + const response = await client.triggerApproval(PermissionsApprovalRequest.method, { + threadId: sessionId, + turnId, + itemId: "perm-item-1", + environmentId: null, + startedAtMs: 0, + cwd, + reason: null, + permissions: {}, + }); + + expect(response).toEqual({permissions: {}, scope: "turn", strictAutoReview: false}); + }); + + it('cancels an MCP elicitation with no prompt running', async () => { + const client = await connectV1(); + + const response = await client.triggerApproval('mcpServer/elicitation/request', { + threadId: sessionId, + turnId: null, + serverName: "test-server", + mode: "form", + _meta: null, + message: "Need input", + requestedSchema: {type: "object", properties: {}}, + }); + + expect(response).toEqual({action: "cancel", content: null, _meta: null}); + }); + + it('answers an empty response for a tool user-input request with no prompt running', async () => { + const client = await connectV1(); + + const response = await client.triggerApproval('item/tool/requestUserInput', { + threadId: sessionId, + turnId, + itemId: "tool-item-1", + questions: [], + isBlocking: true, + autoResolutionMs: null, + }); + + expect(response).toEqual({answers: {}}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/prompt-v2.test.ts b/src/__tests__/CodexACPAgent/prompt-v2.test.ts new file mode 100644 index 000000000..feaa913f9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/prompt-v2.test.ts @@ -0,0 +1,1029 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ResponseError} from 'vscode-jsonrpc'; +import { + sessionId, + turnId, + connectSession, + createTurn, + userMessageItem, + itemStarted, + itemCompleted, + turnStarted, + turnCompleted, + settle, + stateUpdates, + indexOf, + isState, + turnFinished, + dump, + type PromptSession, + type TranscriptEntry, +} from './v2-prompt-harness'; +import type {ServerNotification} from '../../app-server'; +import type {CodexErrorInfo, ErrorNotification, ThreadGoal, TurnCompletedNotification} from '../../app-server/v2'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; +import {CodexAcpServer} from '../../CodexAcpServer'; + +const typedFailureCapabilities = {_meta: {jetbrains: {air: {version: 1, capabilities: ["sessionFailure"]}}}}; + +function createThreadGoal(overrides?: Partial): ThreadGoal { + return { + threadId: sessionId, + objective: "Ship it", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +/** Sends a prompt and lets Codex record its user message; resolves once the prompt is answered. */ +async function insertPrompt(client: PromptSession, index: number, id = turnId) { + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(index + 1)); + const clientUserMessageId = client.turnStartParams[index]!["clientUserMessageId"] as string; + client.emit(turnStarted(id)); + client.emit(itemCompleted(userMessageItem(clientUserMessageId), id)); + const {messageId} = await response; + return messageId as string; +} + +function turnError(codexErrorInfo: CodexErrorInfo, message: string, id = turnId): ServerNotification { + return { + method: "error", + params: { + threadId: sessionId, + turnId: id, + willRetry: false, + error: {message, codexErrorInfo, additionalDetails: null, misalignment: null}, + }, + }; +} + +function sessionUpdates(transcript: TranscriptEntry[]) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []); +} + +/** The transcript with the prompt's and the minted agent messages' ids replaced by placeholders. */ +function stableTranscript(transcript: TranscriptEntry[], messageId: string): unknown { + let json = dump(transcript, messageId); + sessionUpdates(transcript).forEach(update => { + if (update.sessionUpdate === "agent_message_chunk") { + json = json.replaceAll(update.messageId as string, ""); + } + }); + return JSON.parse(json); +} + +describe('session/prompt over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('answers with the messageId only once Codex records the user message', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + // A user message from someone else in the same turn does not insert this prompt. + client.emit(itemCompleted(userMessageItem(null, "Other"))); + await settle(); + expect(client.transcript.some(entry => "promptResponse" in entry)).toBe(false); + + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(itemStarted(userMessageItem(clientUserMessageId))); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + const {messageId} = await response; + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + expect(clientUserMessageId).toEqual(expect.any(String)); + expect(messageId).toBe(clientUserMessageId); + const userMessages = client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "user_message_chunk"); + expect(userMessages).toEqual([{sessionUpdate: "user_message_chunk", messageId, content: {type: "text", text: "Hello"}}]); + // Insertion (response + user message) comes first, then `running`, then exactly one `idle`. + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const running = indexOf(client.transcript, isState("running")); + expect(indexOf(client.transcript, entry => "promptResponse" in entry)).toBeLessThan(running); + expect(indexOf(client.transcript, entry => "sessionUpdate" in entry + && entry.sessionUpdate.sessionUpdate === "user_message_chunk")).toBeLessThan(running); + expect(running).toBeLessThan(indexOf(client.transcript, isState("idle"))); + expect(indexOf(client.transcript, isState("idle"))) + .toBeGreaterThan(indexOf(client.transcript, entry => "codexNotification" in entry + && entry.codexNotification === "turn/completed")); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-inserted.json'); + }); + + it('ends a turn that fails or is interrupted after insertion with one idle and the v1 stop reason', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const results = []; + for (const [index, status] of (["failed", "interrupted"] as const).entries()) { + const id = `turn-${index + 1}`; + const start = client.transcript.length; + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(index + 1)); + const clientUserMessageId = client.turnStartParams[index]!["clientUserMessageId"] as string; + client.emit(turnStarted(id)); + client.emit(itemCompleted(userMessageItem(clientUserMessageId), id)); + await response; + client.emit(turnFinished(status, id)); + await client.promptRunFinished(index); + await settle(); + results.push({status, transcript: JSON.parse(dump(client.transcript.slice(start), clientUserMessageId))}); + expect(stateUpdates(client.transcript.slice(start))) + .toEqual([{state: "running"}, {state: "idle", stopReason: status === "failed" ? "end_turn" : "cancelled"}]); + } + + await expect(dump(results)).toMatchFileSnapshot('data/prompt-v2-turn-failed-or-interrupted.json'); + }); + + it('shows a usage-limit or auth error after insertion as agent text, then one idle/end_turn', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const results = []; + for (const [index, [info, message, expectedText]] of ([ + ["usageLimitExceeded", "You've hit your usage limit.", "You've hit your usage limit.\n\n"], + // unauthorized routes through the ACP login flow instead of echoing the provider's text. + ["unauthorized", "Your access token could not be refreshed.", "Authentication required"], + ] as const).entries()) { + const id = `turn-${index + 1}`; + const start = client.transcript.length; + const messageId = await insertPrompt(client, index, id); + client.emit(turnError(info, message, id)); + client.emit(turnFinished("failed", id)); + await client.promptRunFinished(index); + await settle(); + + const transcript = client.transcript.slice(start); + // v1 fails this prompt with a JSON-RPC error; v2 has answered it already, so the + // error text the turn sent is all the client gets, and the turn still ends. + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const agentMessages = sessionUpdates(transcript).filter(update => update.sessionUpdate === "agent_message_chunk"); + expect(agentMessages).toEqual([expect.objectContaining({content: {type: "text", text: expectedText}})]); + const text = indexOf(transcript, entry => "sessionUpdate" in entry + && entry.sessionUpdate.sessionUpdate === "agent_message_chunk"); + expect(indexOf(transcript, isState("running"))).toBeLessThan(text); + expect(text).toBeLessThan(indexOf(transcript, isState("idle"))); + results.push({info, transcript: stableTranscript(transcript, messageId)}); + } + + await expect(dump(results)).toMatchFileSnapshot('data/prompt-v2-turn-error-after-insertion.json'); + }); + + it('puts a typed terminal failure on the idle _meta, not in a session update', async () => { + const client = await connectSession(2, {clientCapabilities: typedFailureCapabilities}); + closeClient = () => client.connection.close(); + + const start = client.transcript.length; + const messageId = await insertPrompt(client, 0); + client.emit(turnError("usageLimitExceeded", "You've hit your usage limit.")); + client.emit(turnFinished("failed")); + await client.promptRunFinished(); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + // Reported once, as v1 reports it once on the prompt response. + expect(sessionUpdates(transcript).map(update => update.sessionUpdate)) + .not.toEqual(expect.arrayContaining(["agent_message_chunk"])); + expect(sessionUpdates(transcript).map(update => update.sessionUpdate)) + .not.toEqual(expect.arrayContaining(["session_info_update"])); + const idle = sessionUpdates(transcript).at(-1) as {_meta?: Record}; + expect(idle._meta?.["jetbrains"]?.air?.sessionFailure).toMatchObject({severity: "error"}); + expect(idle._meta?.["quota"]).toBeDefined(); + await expect(dump(stableTranscript(transcript, messageId))) + .toMatchFileSnapshot('data/prompt-v2-typed-failure-after-insertion.json'); + + // The session takes the next prompt. + await insertPrompt(client, 1, "turn-2"); + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + }); + + it('ends the turn with one idle when the Codex process exits after insertion', async () => { + const results = []; + for (const typed of [false, true]) { + let exitCode: number | null = null; + const client = await connectSession(2, { + exitCode: () => exitCode, + ...(typed ? {clientCapabilities: typedFailureCapabilities} : {}), + }); + closeClient = () => client.connection.close(); + let loseTransport: (error: Error) => void = () => {}; + vi.spyOn(client.appServer, "awaitTurnCompleted").mockImplementation(() => + new Promise((_, reject) => { + loseTransport = reject; + })); + + const start = client.transcript.length; + const messageId = await insertPrompt(client, 0); + exitCode = 1; + loseTransport(new Error("connection closed")); + await client.promptRunFinished(); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const agentMessages = sessionUpdates(transcript).filter(update => update.sessionUpdate === "agent_message_chunk"); + const idle = sessionUpdates(transcript).at(-1) as {_meta?: Record}; + if (typed) { + // Typed-failure clients get the synthetic `transport_lost` failure, as on v1. + expect(agentMessages).toEqual([]); + expect(idle._meta?.["jetbrains"]?.air?.sessionFailure).toMatchObject({category: "connection"}); + } else { + expect(agentMessages).toEqual([expect.objectContaining({ + content: {type: "text", text: "Codex process has exited with code 1"}, + })]); + expect(idle._meta?.["jetbrains"]).toBeUndefined(); + } + results.push({typed, transcript: stableTranscript(transcript, messageId)}); + client.connection.close(); + closeClient = null; + expectConformingV2SessionUpdates(); + } + + await expect(dump(results)).toMatchFileSnapshot('data/prompt-v2-process-exit-after-insertion.json'); + }); + + it('ends a failing local command with its error as agent text and one idle', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + client.setCodexResponse("thread/name/set", async () => { + throw new ResponseError(-32603, "thread name could not be saved"); + }); + + const {messageId} = await client.sendPrompt([{type: "text", text: "/rename New name"}]); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const agentMessages = sessionUpdates(client.transcript).filter(update => update.sessionUpdate === "agent_message_chunk"); + expect(agentMessages).toEqual([expect.objectContaining({ + content: {type: "text", text: "The '/rename' command failed: thread name could not be saved"}, + })]); + await expect(dump(stableTranscript(client.transcript, messageId))) + .toMatchFileSnapshot('data/prompt-v2-local-command-failed.json'); + + const second = client.sendPrompt([{type: "text", text: "/plan"}]); + await expect(second).resolves.toEqual({messageId: expect.any(String)}); + await client.promptRunFinished(1); + }); + + it('carries the v1 usage and quota on the idle that ends a turn', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + await insertPrompt(client, 0); + const breakdown = { + totalTokens: 1200, + inputTokens: 1000, + cachedInputTokens: 400, + cacheWriteInputTokens: 0, + outputTokens: 200, + reasoningOutputTokens: 50, + }; + client.emit({ + method: "thread/tokenUsage/updated", + params: {threadId: sessionId, turnId, tokenUsage: {total: breakdown, last: breakdown, modelContextWindow: 200000}}, + }); + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const idle = sessionUpdates(client.transcript).at(-1); + await expect(dump(idle)).toMatchFileSnapshot('data/prompt-v2-idle-usage-and-quota.json'); + }); + + it('fails the prompt when turn/start is rejected', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + client.setTurnStart(async () => { + throw new ResponseError(-32600, "thread not found: thread-1"); + }); + + const error = await client.sendPrompt([{type: "text", text: "Hello"}]).then(() => null, (err) => err); + await client.promptRunFinished(); + + expect(error).not.toBeNull(); + // Never inserted, so the session never left idle. + expect(stateUpdates(client.transcript)).toEqual([]); + // Not inserted: the prompt never happened from the client's view, so no title either. + expect(sessionUpdates(client.transcript).map(update => update.sessionUpdate)) + .not.toEqual(expect.arrayContaining(["session_info_update"])); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + await expect(dump(client.transcript, clientUserMessageId)) + .toMatchFileSnapshot('data/prompt-v2-turn-start-rejected.json'); + }); + + it('fails the prompt when the turn completes without recording the user message', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + // E.g. a blocking UserPromptSubmit hook: the turn ends and Codex never records the prompt. + client.emit(turnStarted()); + client.emit(turnCompleted()); + const error = await response.then(() => null, (err) => err); + await client.promptRunFinished(); + + expect(error).not.toBeNull(); + expect(client.transcript.some(entry => "sessionUpdate" in entry + && entry.sessionUpdate.sessionUpdate === "user_message_chunk")).toBe(false); + expect(stateUpdates(client.transcript)).toEqual([]); + // Not inserted: the fallback title from the prompt text must not be published either. + expect(sessionUpdates(client.transcript).map(update => update.sessionUpdate)) + .not.toEqual(expect.arrayContaining(["session_info_update"])); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + await expect(dump(client.transcript, clientUserMessageId)).toMatchFileSnapshot('data/prompt-v2-not-inserted.json'); + }); + + it('queues a prompt that overlaps a running one instead of rejecting it', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const first = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await first; + + // B overlaps A: it is queued, not rejected. Nothing about it is observable yet. + const second = client.sendPrompt([{type: "text", text: "Again"}]); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + expect(client.transcript.filter(entry => "promptResponse" in entry || "promptError" in entry)).toHaveLength(1); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + // A ends: idle(A) goes out, then B's turn starts. + client.emit(turnCompleted()); + await client.promptRunFinished(); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + + const secondClientUserMessageId = client.turnStartParams[1]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(secondClientUserMessageId, "Again"), "turn-2")); + const {messageId} = await second; + expect(messageId).toBe(secondClientUserMessageId); + + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('queues multiple overlapping prompts and runs them in FIFO order', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const first = client.sendPrompt([{type: "text", text: "One"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await first; + + const second = client.sendPrompt([{type: "text", text: "Two"}]); + const third = client.sendPrompt([{type: "text", text: "Three"}]); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + + client.emit(turnCompleted()); + await client.promptRunFinished(); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + // C stays queued behind B; it must not start alongside or ahead of it. + await settle(); + expect(client.turnStartParams).toHaveLength(2); + + const secondClientUserMessageId = client.turnStartParams[1]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(secondClientUserMessageId, "Two"), "turn-2")); + await second; + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(3)); + const thirdClientUserMessageId = client.turnStartParams[2]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-3")); + client.emit(itemCompleted(userMessageItem(thirdClientUserMessageId, "Three"), "turn-3")); + await third; + client.emit(turnCompleted("turn-3")); + await client.promptRunFinished(2); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('queues a local slash command behind a running prompt', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const first = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await first; + + const second = client.sendPrompt([{type: "text", text: "/plan"}]); + await settle(); + expect(client.transcript.filter(entry => "promptResponse" in entry || "promptError" in entry)).toHaveLength(1); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + client.emit(turnCompleted()); + await client.promptRunFinished(); + + const {messageId} = await second; + expect(messageId).toEqual(expect.any(String)); + await client.promptRunFinished(1); + await settle(); + + // The local command never talks to Codex's turn machinery. + expect(client.turnStartParams).toHaveLength(1); + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('runs a queued prompt after the one ahead of it fails', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + await insertPrompt(client, 0); + const second = client.sendPrompt([{type: "text", text: "Again"}]); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + + client.emit(turnError("usageLimitExceeded", "You've hit your usage limit.")); + client.emit(turnFinished("failed")); + await client.promptRunFinished(); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + const secondClientUserMessageId = client.turnStartParams[1]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(secondClientUserMessageId, "Again"), "turn-2")); + await second; + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('takes the turn-start reservation synchronously, so two prompts sent back-to-back never race', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + // Neither request is awaited before the next is sent: this exercises the reservation + // being taken before either prompt's handler has had a chance to run any async work. + const first = client.sendPrompt([{type: "text", text: "One"}]); + const second = client.sendPrompt([{type: "text", text: "Two"}]); + + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(client.turnStartParams[0]!["clientUserMessageId"] as string))); + await first; + client.emit(turnCompleted()); + await client.promptRunFinished(); + + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + const secondClientUserMessageId = client.turnStartParams[1]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(secondClientUserMessageId, "Two"), "turn-2")); + await second; + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + {state: "running"}, {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('keeps the turn going across a rendered tool call', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const first = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await first; + // A file edit, rendered as a tool call with diff content. + client.emit(itemStarted({ + type: "fileChange", + id: "item-edit", + status: "inProgress", + changes: [{path: "/workspace/new.ts", kind: {type: "add"}, diff: "export {};\n"}], + })); + client.emit(turnCompleted()); + await client.promptRunFinished(); + + // The session is idle again: the next prompt starts a new turn. + const second = client.sendPrompt([{type: "text", text: "Again"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + const secondClientUserMessageId = client.turnStartParams[1]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(secondClientUserMessageId, "Again"), "turn-2")); + const {messageId} = await second; + expect(messageId).toBe(secondClientUserMessageId); + expect(secondClientUserMessageId).not.toBe(clientUserMessageId); + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + }); + + it('inserts a locally handled command itself', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const {messageId} = await client.sendPrompt([{type: "text", text: "/plan"}]); + await client.promptRunFinished(); + await settle(); + + expect(client.turnStartParams).toEqual([]); + // response + user message → `running` → command output → `idle`/`end_turn`. + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const running = indexOf(client.transcript, isState("running")); + expect(indexOf(client.transcript, entry => "promptResponse" in entry)).toBeLessThan(running); + expect(running).toBeLessThan(indexOf(client.transcript, entry => "sessionUpdate" in entry + && entry.sessionUpdate.sessionUpdate === "config_option_update")); + expect(indexOf(client.transcript, entry => "sessionUpdate" in entry + && entry.sessionUpdate.sessionUpdate === "config_option_update")) + .toBeLessThan(indexOf(client.transcript, isState("idle"))); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-local-command.json'); + + // The session is idle again afterwards. + const second = client.sendPrompt([{type: "text", text: "/plan"}]); + await expect(second).resolves.toEqual({messageId: expect.any(String)}); + await client.promptRunFinished(1); + }); + + it('sends a local command reply as its own agent message', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const {messageId} = await client.sendPrompt([{type: "text", text: "/skills"}]); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const reply = client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .find(update => update.sessionUpdate === "agent_message_chunk") as {messageId: string} | undefined; + expect(reply?.messageId).toEqual(expect.any(String)); + expect(reply?.messageId).not.toBe(messageId); + await expect(dump(client.transcript, messageId).replaceAll(reply!.messageId, "")) + .toMatchFileSnapshot('data/prompt-v2-local-command-reply.json'); + const second = client.sendPrompt([{type: "text", text: "/plan"}]); + await expect(second).resolves.toEqual({messageId: expect.any(String)}); + await client.promptRunFinished(1); + }); + + it('runs /compact as a Codex command turn, inserting a live-only user message', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const response = client.sendPrompt([{type: "text", text: "/compact"}]); + const {messageId} = await response; + client.emit(turnStarted()); + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + expect(client.turnStartParams).toEqual([]); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-compact-command.json'); + }); + + it('runs /goal as a Codex command turn, inserting a live-only user message', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const goal = createThreadGoal(); + client.setCodexResponse("thread/goal/set", async () => ({goal})); + + const response = client.sendPrompt([{type: "text", text: "/goal Ship it"}]); + const {messageId} = await response; + client.emit({method: "thread/goal/updated", params: {threadId: sessionId, turnId: null, goal}}); + client.emit(turnStarted()); + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + expect(client.turnStartParams).toEqual([]); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-goal-command.json'); + }); + + it('runs /goal resume as a Codex command turn, inserting a live-only user message', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const goal = createThreadGoal({objective: "Resumed objective"}); + client.setCodexResponse("thread/goal/set", async () => ({goal})); + + const response = client.sendPrompt([{type: "text", text: "/goal resume"}]); + const {messageId} = await response; + client.emit({method: "thread/goal/updated", params: {threadId: sessionId, turnId: null, goal}}); + client.emit(turnStarted()); + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + expect(client.turnStartParams).toEqual([]); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-goal-resume-command.json'); + }); + + it('fails the prompt when a Codex command turn is rejected before it starts', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + client.setCodexResponse("thread/compact/start", async () => { + throw new ResponseError(-32600, "Cannot compact now"); + }); + + const error = await client.sendPrompt([{type: "text", text: "/compact"}]).then(() => null, (err) => err); + await client.promptRunFinished(); + + expect(error).not.toBeNull(); + // Never inserted, so the session never left idle. + expect(stateUpdates(client.transcript)).toEqual([]); + await expect(dump(client.transcript)).toMatchFileSnapshot('data/prompt-v2-compact-turn-start-rejected.json'); + }); + + it('ends a /compact turn that fails after insertion with one idle', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const response = client.sendPrompt([{type: "text", text: "/compact"}]); + const {messageId} = await response; + client.emit(turnStarted()); + client.emit(turnFinished("failed")); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-compact-failed-after-insertion.json'); + }); + + it('runs /review as a Codex command turn, inserting a live-only user message and hiding the reviewer prompt', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const parentTurnId = "review-parent"; + const childTurnId = "review-child"; + client.setCodexResponse("review/start", async () => ({ + reviewThreadId: sessionId, + turn: createTurn("inProgress", parentTurnId), + })); + + const response = client.sendPrompt([{type: "text", text: "/review"}]); + const {messageId} = await response; + // The review's own turn id (`parentTurnId`) is not the same as the reviewer's child turn + // (`childTurnId`) that actually starts and is later interrupted: `review/start`'s response + // is the completion id, `turn/started` is the interrupt id. + client.emit(turnStarted(childTurnId)); + client.emit(itemCompleted({ + type: "enteredReviewMode", + id: "entered-review", + review: "current changes", + }, parentTurnId)); + // Codex's own reviewer prompt, `clientId: null`, must never be surfaced as a user message. + client.emit(itemCompleted(userMessageItem(null, "Review the current changes."), parentTurnId)); + client.emit(turnFinished("completed", parentTurnId)); + await client.promptRunFinished(); + await settle(); + + expect(client.turnStartParams).toEqual([]); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + const userMessages = sessionUpdates(client.transcript).filter(update => update.sessionUpdate === "user_message_chunk"); + expect(userMessages).toEqual([{sessionUpdate: "user_message_chunk", messageId, content: {type: "text", text: "/review"}}]); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-review-command.json'); + }); + + it('fails the prompt when review/start is rejected before it starts', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + client.setCodexResponse("review/start", async () => { + throw new ResponseError(-32600, "Cannot review right now"); + }); + + const error = await client.sendPrompt([{type: "text", text: "/review"}]).then(() => null, (err) => err); + await client.promptRunFinished(); + + expect(error).not.toBeNull(); + // Never inserted, so the session never left idle. + expect(stateUpdates(client.transcript)).toEqual([]); + await expect(dump(client.transcript)).toMatchFileSnapshot('data/prompt-v2-review-turn-start-rejected.json'); + }); + + it('ends a review Codex could not resolve before starting it with exactly one idle', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const parentTurnId = "review-parent"; + client.setCodexResponse("review/start", async () => ({ + reviewThreadId: sessionId, + turn: createTurn("inProgress", parentTurnId), + })); + + const response = client.sendPrompt([{type: "text", text: "/review"}]); + const {messageId} = await response; + // Codex reports a fatal, non-retried error under the review's turn id and never spawns the + // reviewer: no `turn/started`, no `enteredReviewMode`, no `turn/completed`. + const notGitRepositoryError: ErrorNotification["error"] = { + message: "/test/cwd is not a git repository", + codexErrorInfo: null, + additionalDetails: null, + misalignment: null, + }; + client.emit({ + method: "error", + params: {threadId: sessionId, turnId: parentTurnId, willRetry: false, error: notGitRepositoryError}, + }); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(stableTranscript(client.transcript, messageId))) + .toMatchFileSnapshot('data/prompt-v2-review-unspawned-error.json'); + }); + + it('ends a /review turn that fails after insertion with one idle', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const parentTurnId = "review-parent"; + const childTurnId = "review-child"; + client.setCodexResponse("review/start", async () => ({ + reviewThreadId: sessionId, + turn: createTurn("inProgress", parentTurnId), + })); + + const response = client.sendPrompt([{type: "text", text: "/review"}]); + const {messageId} = await response; + client.emit(turnStarted(childTurnId)); + client.emit(turnFinished("failed", parentTurnId)); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-review-failed-after-insertion.json'); + }); + + it('rejects content block types that only exist on v2', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const error = await client.sendPrompt([{type: "_custom", value: 1}]).then( + () => null, + (err) => ({code: err.code, message: err.message, data: err.data}), + ); + + expect(client.turnStartParams).toEqual([]); + await expect(dump(error)).toMatchFileSnapshot('data/prompt-v2-unsupported-content.json'); + }); + + it('completes /goal resume with no continuation turn when no runtime turn is observed', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const goal = createThreadGoal({objective: "Resumed objective"}); + client.setCodexResponse("thread/goal/set", async () => ({goal})); + + const response = client.sendPrompt([{type: "text", text: "/goal resume"}]); + const {messageId} = await response; + client.emit({method: "thread/goal/updated", params: {threadId: sessionId, turnId: null, goal}}); + // Codex reports no active runtime turn for this goal within the grace window. Codex + // auto-continues active goals itself, so codex-acp starts no turn of its own here. + await client.promptRunFinished(); + await settle(); + + expect(client.turnStartParams).toEqual([]); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, messageId)).toMatchFileSnapshot('data/prompt-v2-goal-resume-no-continuation.json'); + }, 10000); + + it('shows the plan-implementation follow-up turn as its own live user message, with its own minted id', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + vi.spyOn(CodexAcpServer.prototype as any, "requestPlanImplementationPermission").mockResolvedValue(true); + + // Switch into plan mode first; `/plan` is a local command and starts no turn. + await client.sendPrompt([{type: "text", text: "/plan"}]); + await client.promptRunFinished(0); + await settle(); + expect(client.turnStartParams).toEqual([]); + const start = client.transcript.length; + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + const {messageId} = await response; + client.emit(itemCompleted({type: "plan", id: "plan-item", text: "1. Do the change."})); + client.emit(turnCompleted()); + + // Approval is mocked, so codex-acp starts the "Implement the approved plan." turn itself, + // still inside this same prompt. + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + const implementationId = client.turnStartParams[1]!["clientUserMessageId"] as string; + expect(implementationId).toEqual(expect.any(String)); + expect(implementationId).not.toBe(messageId); + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(implementationId, "Implement the approved plan."), "turn-2")); + await settle(); + const userMessages = sessionUpdates(client.transcript.slice(start)).filter(update => update.sessionUpdate === "user_message_chunk"); + expect(userMessages).toEqual([ + {sessionUpdate: "user_message_chunk", messageId, content: {type: "text", text: "Hello"}}, + { + sessionUpdate: "user_message_chunk", + messageId: implementationId, + content: {type: "text", text: "Implement the approved plan."}, + }, + ]); + + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + await settle(); + + // One running…idle pair for the whole exchange, despite the two turns. + expect(stateUpdates(client.transcript.slice(start))).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript.slice(start), messageId).replaceAll(implementationId, "")) + .toMatchFileSnapshot('data/prompt-v2-plan-implementation-turn.json'); + }); + + it('leaves v1 session/prompt unchanged: no clientUserMessageId, answered at turn end', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(null))); + await settle(); + expect(client.transcript.some(entry => "promptResponse" in entry)).toBe(false); + client.emit(turnCompleted()); + await response; + + expect(client.turnStartParams[0]).not.toHaveProperty("clientUserMessageId"); + expect(stateUpdates(client.transcript)).toEqual([]); + await expect(dump(client.transcript)).toMatchFileSnapshot('data/prompt-v2-v1-unchanged.json'); + }); + + it('adopts a prompt steered into an already-running unowned turn without a second `running` (J8)', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + // An unowned turn (e.g. a `/goal` auto-continuation) is already running and has already + // sent its own `running` before this prompt exists. + client.emit(turnStarted("goal-turn")); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + const start = client.transcript.length; + client.setTurnStart(async () => ({turn: createTurn("inProgress", "goal-turn")})); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(itemCompleted(userMessageItem(clientUserMessageId), "goal-turn")); + const {messageId} = await response; + expect(messageId).toBe(clientUserMessageId); + + client.emit(turnCompleted("goal-turn")); + await client.promptRunFinished(); + await settle(); + + const transcript = client.transcript.slice(start); + // No second `running`: the goal turn's own `running` already covers this prompt. Exactly + // one `idle`, sent by this prompt once the adopted turn ends. + expect(stateUpdates(transcript)).toEqual([{state: "idle", stopReason: "end_turn"}]); + const userMessages = transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "user_message_chunk"); + expect(userMessages).toEqual([{sessionUpdate: "user_message_chunk", messageId, content: {type: "text", text: "Hello"}}]); + }); + + it('fails with a -32800 JSON-RPC error, and still sends exactly one idle, when the adopted turn ends before the steered input lands (M2)', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + client.emit(turnStarted("goal-turn")); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + const start = client.transcript.length; + client.setTurnStart(async () => ({turn: createTurn("inProgress", "goal-turn")})); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + + // Codex drops the steered input: the adopted turn is interrupted before it lands. + client.emit(turnFinished("interrupted", "goal-turn")); + await client.promptRunFinished(); + await settle(); + + // Never inserted, and the turn ended cancelled: same `-32800` a dropped queued prompt gets. + await expect(response).rejects.toMatchObject({ + code: -32800, + message: "Request cancelled: The prompt ended before Codex recorded the user message", + }); + const transcript = client.transcript.slice(start); + expect(transcript.some(entry => "sessionUpdate" in entry && entry.sessionUpdate.sessionUpdate === "user_message_chunk")) + .toBe(false); + // Exactly one `idle` for the adopted turn: nothing else will send it, since this prompt + // is the one that claimed ownership (and suppressed the baseline unowned-turn tracker). + expect(stateUpdates(transcript)).toEqual([{state: "idle", stopReason: "cancelled"}]); + }); + + it('adopts a foreign turn id returned right after session/resume, with no unowned turn known yet (J10)', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const start = client.transcript.length; + client.setTurnStart(async () => { + // Codex's own goal auto-continuation wins the race started right after + // `session/resume`: its `turn/started` arrives while our request is still pending, so + // there was no prior known unowned turn to snapshot. + client.emit(turnStarted("goal-turn")); + return {turn: createTurn("inProgress", "goal-turn")}; + }); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(itemCompleted(userMessageItem(clientUserMessageId), "goal-turn")); + const {messageId} = await response; + expect(messageId).toBe(clientUserMessageId); + + client.emit(turnCompleted("goal-turn")); + await client.promptRunFinished(); + await settle(); + + // This prompt was in flight before `turn/started` arrived, so the baseline unowned-turn + // tracker is suppressed and this prompt sends its own running/idle -- exactly once each. + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + }); + + it('adopts a turn with activity both before and during the steer, without duplicate states (M2)', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + client.emit(turnStarted("goal-turn")); + client.emit(itemCompleted(userMessageItem(null, "Other"), "goal-turn")); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + const start = client.transcript.length; + client.setTurnStart(async () => { + // More goal-turn activity arrives while `turn/start` is still pending. + client.emit(itemCompleted(userMessageItem(null, "More"), "goal-turn")); + return {turn: createTurn("inProgress", "goal-turn")}; + }); + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(itemCompleted(userMessageItem(clientUserMessageId), "goal-turn")); + const {messageId} = await response; + expect(messageId).toBe(clientUserMessageId); + + client.emit(turnCompleted("goal-turn")); + await client.promptRunFinished(); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "idle", stopReason: "end_turn"}]); + const userMessages = transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "user_message_chunk"); + expect(userMessages).toEqual([{sessionUpdate: "user_message_chunk", messageId, content: {type: "text", text: "Hello"}}]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/provider-restart-turn-tracking.test.ts b/src/__tests__/CodexACPAgent/provider-restart-turn-tracking.test.ts new file mode 100644 index 000000000..0e343ab0b --- /dev/null +++ b/src/__tests__/CodexACPAgent/provider-restart-turn-tracking.test.ts @@ -0,0 +1,401 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {OPENAI_PROVIDER_ID} from '../../CodexAcpClient'; +import { + sessionId, + connectSession, + createReplacementCodexAcpClient, + createTurn, + userMessageItem, + commandExecutionItem, + itemStarted, + itemCompleted, + turnStarted, + turnCompleted, + agentMessageDelta, + settle, + stateUpdates, + indexOf, + isState, + type PromptSession, + type TranscriptEntry, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +function toolCallUpdates(transcript: TranscriptEntry[]) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "tool_call_update"); +} + +function agentMessageChunks(transcript: PromptSession['transcript']) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "agent_message_chunk"); +} + +function setProviderParams() { + return { + providerId: OPENAI_PROVIDER_ID, + apiType: "openai" as const, + baseUrl: "https://gateway.example/v1", + }; +} + +/** Runs a normal prompt to completion on `client`, so `v2PromptsInFlight` no longer owns the session. */ +async function runPromptToCompletion(client: PromptSession) { + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); +} + +/** + * A provider restart (`providers/set`/`providers/disable`) reinstalls the + * baseline Codex turn tracker on the replacement client before re-resuming each session, and closes out a turn left + * running on the old client with a v2-only `idle`/`cancelled` (fix 2), since the old app-server process's EOF drops the notification and + * no `turn/completed` for it ever arrives. + */ +describe('provider restart reinstalls the baseline turn tracker', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + expectConformingV2SessionUpdates(); + }); + + it('v2: renders a Codex-started turn after providers/set, for a session never prompted', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + + await client.request(acpV2.methods.agent.providers.set, setProviderParams()); + + replacement.emit(turnStarted()); + replacement.emit(agentMessageDelta("Working on the goal")); + replacement.emit(turnCompleted()); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v1: renders a Codex-started turn after providers/set, for a session never prompted', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + + await client.request(acp.methods.agent.providers.set, setProviderParams()); + + replacement.emit(turnStarted()); + replacement.emit(agentMessageDelta("Working on the goal")); + replacement.emit(turnCompleted()); + await settle(); + + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v2: also renders a Codex-started turn after restart for a session that had been prompted before it', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + await runPromptToCompletion(client); + const beforeRestart = client.transcript.length; + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + await client.request(acpV2.methods.agent.providers.set, setProviderParams()); + + replacement.emit(turnStarted("goal-turn")); + replacement.emit(agentMessageDelta("Working on the goal", "goal-turn")); + replacement.emit(turnCompleted("goal-turn")); + await settle(); + + const afterRestart = client.transcript.slice(beforeRestart); + expect(stateUpdates(afterRestart)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + expect(agentMessageChunks(afterRestart)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v2: closes out a turn still running at restart with exactly one idle/cancelled, then a later prompt runs normally', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + + // An unowned (Codex-started) turn is already running when the restart begins. + client.emit(turnStarted()); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + await client.request(acpV2.methods.agent.providers.set, setProviderParams()); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + ]); + const sessions = (client.agent as unknown as {sessions: Map}).sessions; + expect(sessions.get(sessionId)?.codexReportedRunningTurnId).toBeNull(); + + // isSessionBusy is back to false: a normal prompt through the replacement client still + // gets a clean running -> idle bracket, with no leftover state from the closed-out turn. + replacement.setTurnStart(async () => ({turn: createTurn("inProgress", "turn-after-restart")})); + const promptResponse = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(replacement.turnStartParams).toHaveLength(1)); + const clientUserMessageId = replacement.turnStartParams[0]!["clientUserMessageId"] as string; + replacement.emit(turnStarted("turn-after-restart")); + replacement.emit(itemCompleted(userMessageItem(clientUserMessageId), "turn-after-restart")); + await promptResponse; + replacement.emit(turnCompleted("turn-after-restart")); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript).slice(-2)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + }); + + it('v2: closes out the cut-off turn before a continuation turn Codex auto-starts right after resume', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + + // An unowned (Codex-started) turn is already running when the restart begins. + client.emit(turnStarted()); + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + + // Simulate Codex auto-starting a continuation turn moments after `thread/resume` + // resolves, by emitting its `turn/started` as a side effect of `resumeSession` itself. + // The close-out for the cut-off turn must happen before this call, not after it. + const originalResumeSession = replacement.codexAcpClient.resumeSession.bind(replacement.codexAcpClient); + vi.spyOn(replacement.codexAcpClient, "resumeSession").mockImplementation(async (...args: Parameters) => { + const result = await originalResumeSession(...args); + replacement.emit(turnStarted("new-turn")); + return result; + }); + + await client.request(acpV2.methods.agent.providers.set, setProviderParams()); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + {state: "running"}, + ]); + expect((client.agent as any).isSessionBusy(sessionId)).toBe(true); + + replacement.emit(agentMessageDelta("Working on the goal", "new-turn")); + replacement.emit(turnCompleted("new-turn")); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v1: a turn still running at restart gets no new frames from the close-out (v2-only)', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + + client.emit(turnStarted()); + await settle(); + const beforeRestart = client.transcript.length; + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + await client.request(acp.methods.agent.providers.set, setProviderParams()); + await settle(); + + expect(client.transcript.length).toBe(beforeRestart); + }); + + it('v2: a failed restart does not wedge the session notification queue', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + vi.spyOn(client.agent as any, "restartCodexClient").mockRejectedValueOnce(new Error("boom")); + + await expect(client.request(acpV2.methods.agent.providers.set, setProviderParams())).rejects.toThrow(); + + // The restart never replaced the client; a normal prompt on the original one must still + // complete, proving its per-session notification queue is not wedged. + await runPromptToCompletion(client); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + }); +}); + +/** + * The cut-off turn's still-open tool calls never get `item/completed` -- the old app-server + * process's EOF drops it -- so without this fix they stay `in_progress` forever. The restart + * close-out must fail them (and end any open terminal) before the turn's own idle/cancelled + * state, on both v1 and v2, and must not touch a tool call that already completed. + */ +describe('provider restart fails outstanding tool calls left open by the cut-off turn (D2)', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + expectConformingV2SessionUpdates(); + }); + + it('v2: fails an in-progress command tool call and ends its terminal, before idle/cancelled', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + + client.emit(turnStarted()); + client.emit(itemStarted(commandExecutionItem("exec-1"))); + await settle(); + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + await client.request(acpV2.methods.agent.providers.set, setProviderParams()); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + ]); + + const failedIndex = indexOf(client.transcript, entry => + "sessionUpdate" in entry + && entry.sessionUpdate.sessionUpdate === "tool_call_update" + && (entry.sessionUpdate as {toolCallId: string, status?: string}).toolCallId === "exec-1" + && (entry.sessionUpdate as {status?: string}).status === "failed"); + const idleIndex = indexOf(client.transcript, isState("idle")); + expect(failedIndex).toBeGreaterThan(-1); + expect(failedIndex).toBeLessThan(idleIndex); + + // v2 renders both the item's creation and its later close-out under the same + // `tool_call_update` tag; only the second one is this fix's synthetic failure. + expect(toolCallUpdates(client.transcript)).toEqual([ + expect.objectContaining({toolCallId: "exec-1", status: "in_progress"}), + {sessionUpdate: "tool_call_update", toolCallId: "exec-1", status: "failed"}, + ]); + const terminalUpdate = client.transcript.find(entry => + "sessionUpdate" in entry + && entry.sessionUpdate.sessionUpdate === "terminal_update" + && "exitStatus" in entry.sessionUpdate); + expect(terminalUpdate).toEqual({ + sessionUpdate: { + sessionUpdate: "terminal_update", + terminalId: "exec-1", + exitStatus: {exitCode: null, signal: null}, + }, + }); + }); + + it('v1: fails an in-progress command tool call and ends its terminal, with nothing else v1-visible changing', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + + client.emit(turnStarted()); + client.emit(itemStarted(commandExecutionItem("exec-1"))); + await settle(); + const beforeRestart = client.transcript.length; + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + await client.request(acp.methods.agent.providers.set, setProviderParams()); + await settle(); + + const newUpdates = client.transcript.slice(beforeRestart) + .flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []); + expect(newUpdates).toEqual([ + expect.objectContaining({ + sessionUpdate: "tool_call_update", + toolCallId: "exec-1", + status: "failed", + _meta: expect.objectContaining({ + terminal_exit: {exit_code: null, signal: null, terminal_id: "exec-1"}, + }), + }), + ]); + }); + + it('v2: does not re-fail a tool call that already completed before the restart', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + + client.emit(turnStarted()); + client.emit(itemStarted(commandExecutionItem("exec-done"))); + client.emit(itemCompleted(commandExecutionItem("exec-done", "completed"))); + await settle(); + // v2 renders both the item's creation and its completion under the same + // `tool_call_update` tag. + expect(toolCallUpdates(client.transcript)).toEqual([ + expect.objectContaining({toolCallId: "exec-done", status: "in_progress"}), + expect.objectContaining({toolCallId: "exec-done", status: "completed"}), + ]); + const beforeRestart = client.transcript.length; + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + await client.request(acpV2.methods.agent.providers.set, setProviderParams()); + await settle(); + + const newToolCallUpdates = toolCallUpdates(client.transcript.slice(beforeRestart)); + expect(newToolCallUpdates).toEqual([]); + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "cancelled"}, + ]); + }); + + it('v2: does not re-fail an item that missed its item/completed but whose own turn already ended', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + + // exec-1 starts and never gets an item/completed, but its own turn (turn-a) ends anyway. + client.emit(turnStarted("turn-a")); + client.emit(itemStarted(commandExecutionItem("exec-1"), "turn-a")); + client.emit(turnCompleted("turn-a")); + await settle(); + + // A later turn (turn-b) is running when the restart happens; exec-1 belongs to the + // already-finished turn-a and must not be failed as if it were cut off by this restart. + client.emit(turnStarted("turn-b")); + await settle(); + + const replacement = createReplacementCodexAcpClient(); + vi.spyOn(client.agent as any, "restartCodexClient").mockResolvedValue(replacement.codexAcpClient); + await client.request(acpV2.methods.agent.providers.set, setProviderParams()); + await settle(); + + expect(toolCallUpdates(client.transcript).some(update => + (update as {toolCallId: string, status?: string}).toolCallId === "exec-1" + && (update as {status?: string}).status === "failed", + )).toBe(false); + }); +}); diff --git a/src/__tests__/CodexACPAgent/providers-v2.test.ts b/src/__tests__/CodexACPAgent/providers-v2.test.ts new file mode 100644 index 000000000..cff2f9f38 --- /dev/null +++ b/src/__tests__/CodexACPAgent/providers-v2.test.ts @@ -0,0 +1,209 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient, OPENAI_PROVIDER_ID} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import {createMockConnections} from './test-utils'; + +type ProtocolVersion = "v1" | "v2"; + +/** + * Connects a v1 or v2 client to the agent through the router, over a mocked Codex app-server. + * `providers/*` never touches a live session in these tests, so the mock just needs to exist. + */ +function connectClient(version: ProtocolVersion) { + const mocks = createMockConnections(); + mocks.mockCodexConnection.sendRequest.mockResolvedValue({}); + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + const router = createAcpAgentRouter((connection) => new CodexAcpServer(connection, codexAcpClient)); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + const clientStream = acp.ndJsonStream(clientToAgent.writable, agentToClient.readable); + + if (version === "v1") { + const connection = acp.client({name: "test-client"}).connect(clientStream); + return { + connection, + initialize: () => connection.agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + }), + providers: { + list: (params: acp.ListProvidersRequest) => + connection.agent.request(acp.methods.agent.providers.list, params), + set: (params: acp.SetProviderRequest) => + connection.agent.request(acp.methods.agent.providers.set, params), + disable: (params: acp.DisableProviderRequest) => + connection.agent.request(acp.methods.agent.providers.disable, params), + }, + }; + } + + const connection = acpV2.client({name: "test-client"}).connect(clientStream); + return { + connection, + initialize: () => connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + }), + providers: { + list: (params: acpV2.ListProvidersRequest) => + connection.agent.request(acpV2.methods.agent.providers.list, params), + set: (params: acpV2.SetProviderRequest) => + connection.agent.request(acpV2.methods.agent.providers.set, params), + disable: (params: acpV2.DisableProviderRequest) => + connection.agent.request(acpV2.methods.agent.providers.disable, params), + }, + }; +} + +function toError(err: {code: number, message: string, data?: unknown}) { + return {code: err.code, message: err.message, data: err.data}; +} + +const VERSIONS: ProtocolVersion[] = ["v1", "v2"]; + +describe('providers/* over ACP v1 and v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + }); + + it.each(VERSIONS)('%s: lists native OpenAI routing before any override', async (version) => { + const client = connectClient(version); + closeClient = () => client.connection.close(); + await client.initialize(); + + const response = await client.providers.list({}); + + expect(response).toEqual({ + providers: [{ + providerId: OPENAI_PROVIDER_ID, + supported: ["openai"], + required: false, + current: {apiType: "openai", baseUrl: "https://api.openai.com/v1"}, + }], + }); + }); + + it.each(VERSIONS)('%s: reflects set routing in list without echoing headers', async (version) => { + const client = connectClient(version); + closeClient = () => client.connection.close(); + await client.initialize(); + + const setResponse = await client.providers.set({ + providerId: OPENAI_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + headers: {Authorization: "Bearer super-secret"}, + }); + const listResponse = await client.providers.list({}); + + expect(setResponse).toEqual({}); + const provider = listResponse.providers[0]!; + expect(provider.current).toEqual({ + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + }); + expect(JSON.stringify(provider)).not.toContain("super-secret"); + }); + + it.each(VERSIONS)('%s: restores native OpenAI routing after disable', async (version) => { + const client = connectClient(version); + closeClient = () => client.connection.close(); + await client.initialize(); + await client.providers.set({ + providerId: OPENAI_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://example.com/v1", + }); + + const disableResponse = await client.providers.disable({providerId: OPENAI_PROVIDER_ID}); + const listResponse = await client.providers.list({}); + + expect(disableResponse).toEqual({}); + expect(listResponse.providers[0]!.current).toEqual({ + apiType: "openai", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it.each(VERSIONS)('%s: treats disabling an unknown providerId as idempotent success', async (version) => { + const client = connectClient(version); + closeClient = () => client.connection.close(); + await client.initialize(); + + const disableResponse = await client.providers.disable({providerId: "not-a-real-provider"}); + + expect(disableResponse).toEqual({}); + }); + + it.each(VERSIONS)('%s: rejects an unsupported apiType with invalid_params', async (version) => { + const client = connectClient(version); + closeClient = () => client.connection.close(); + await client.initialize(); + + const error = await client.providers.set({ + providerId: OPENAI_PROVIDER_ID, + apiType: "anthropic", + baseUrl: "https://example.com", + } as never).then(() => null, toError); + + expect(error?.code).toBe(-32602); + }); + + it.each(VERSIONS)('%s: rejects an unknown providerId with invalid_params', async (version) => { + const client = connectClient(version); + closeClient = () => client.connection.close(); + await client.initialize(); + + const error = await client.providers.set({ + providerId: "does-not-exist", + apiType: "openai", + baseUrl: "https://example.com", + }).then(() => null, toError); + + expect(error?.code).toBe(-32602); + }); + + // The v2 SDK validates `baseUrl` as `format: uri` before codex-acp's own handler runs. + // v1's schema has no such format constraint, so v1's handler-level check (non-empty string + // only) is all that applies there. + it('v2 only: rejects a non-URL baseUrl with invalid_params from the SDK', async () => { + const client = connectClient("v2"); + closeClient = () => client.connection.close(); + await client.initialize(); + + const error = await client.providers.set({ + providerId: OPENAI_PROVIDER_ID, + apiType: "openai", + baseUrl: "not-a-url", + }).then(() => null, toError); + + expect(error?.code).toBe(-32602); + }); + + it('v1 pin: accepts a non-URL baseUrl (no format validation on v1)', async () => { + const client = connectClient("v1"); + closeClient = () => client.connection.close(); + await client.initialize(); + + const setResponse = await client.providers.set({ + providerId: OPENAI_PROVIDER_ID, + apiType: "openai", + baseUrl: "not-a-url", + }); + const listResponse = await client.providers.list({}); + + expect(setResponse).toEqual({}); + expect(listResponse.providers[0]!.current).toEqual({ + apiType: "openai", + baseUrl: "not-a-url", + }); + }); +}); diff --git a/src/__tests__/CodexACPAgent/resume-goal-turn-timing.test.ts b/src/__tests__/CodexACPAgent/resume-goal-turn-timing.test.ts new file mode 100644 index 000000000..2f5b39038 --- /dev/null +++ b/src/__tests__/CodexACPAgent/resume-goal-turn-timing.test.ts @@ -0,0 +1,216 @@ +import {afterEach, describe, expect, it} from 'vitest'; +import type {ServerNotification} from '../../app-server'; +import type {Thread} from '../../app-server/v2'; +import { + cwd, + codexResponse, + connectSession, + createThread, + createTurn, + settle, + stateUpdates, + type PromptSession, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +/** + * A second thread id, distinct from `sessionId` ("thread-1") that `connectSession` already opens + * via `session/new`. Resuming/loading this one exercises a session that has no notification + * handler registered yet, unlike "thread-1". + */ +const secondSessionId = "thread-2"; +const secondTurnId = "turn-2a"; + +function turnStartedFor(threadId: string, id: string): ServerNotification { + return {method: "turn/started", params: {threadId, turn: createTurn("inProgress", id)}}; +} + +function turnCompletedFor(threadId: string, id: string): ServerNotification { + return {method: "turn/completed", params: {threadId, turn: createTurn("completed", id)}}; +} + +function agentMessageDeltaFor(threadId: string, turnId: string, delta: string): ServerNotification { + return {method: "item/agentMessage/delta", params: {threadId, turnId, itemId: "goal-item-1", delta}}; +} + +function agentMessageChunks(transcript: PromptSession['transcript']) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "agent_message_chunk"); +} + +/** + * Fires a goal turn's opening notifications for `secondSessionId` from inside a Codex response + * handler, simulating Codex auto-starting a goal turn a few ms after `thread/resume` returns -- + * before codex-acp's own post-resume setup (`model/list`, `account/read`, history reads) has + * finished and installed the session's real notification handler. + */ +function fireGoalTurnDuring(client: PromptSession, method: string) { + client.setCodexResponse(method, async (params) => { + client.emit(turnStartedFor(secondSessionId, secondTurnId)); + client.emit(agentMessageDeltaFor(secondSessionId, secondTurnId, "Working on the goal")); + return codexResponse(method); + }); +} + +describe('baseline tracker installed before thread/resume, so an auto-started goal turn is not lost', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + expectConformingV2SessionUpdates(); + }); + + it('v2 session/resume: renders the goal turn started during setup, no lone idle', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + fireGoalTurnDuring(client, "model/list"); + + await client.request("session/resume", {sessionId: secondSessionId, cwd}); + await settle(); + client.emit(turnCompletedFor(secondSessionId, secondTurnId)); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v2 session/resume with replayFrom: start: renders the goal turn started during setup, no lone idle', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + fireGoalTurnDuring(client, "model/list"); + + client.setCodexResponse("thread/read", async () => ({thread: createThread()})); + await client.request("session/resume", {sessionId: secondSessionId, cwd, replayFrom: {type: "start"}}); + await settle(); + client.emit(turnCompletedFor(secondSessionId, secondTurnId)); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v1 session/resume: renders the goal turn started during setup', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + fireGoalTurnDuring(client, "model/list"); + + await client.request("session/resume", {sessionId: secondSessionId, cwd, mcpServers: []}); + await settle(); + client.emit(turnCompletedFor(secondSessionId, secondTurnId)); + await settle(); + + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v1 session/load: renders the goal turn started during setup', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + client.setCodexResponse("thread/read", async () => ({thread: createThread()})); + fireGoalTurnDuring(client, "model/list"); + + await client.request("session/load", {sessionId: secondSessionId, cwd, mcpServers: []}); + await settle(); + client.emit(turnCompletedFor(secondSessionId, secondTurnId)); + await settle(); + + expect(agentMessageChunks(client.transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('v1 session/load: a live goal turn started during setup renders after replayed history, not interleaved', async () => { + const client = await connectSession(1); + closeClient = () => client.connection.close(); + const replayedTurn: Thread["turns"][number] = { + id: "replayed-turn", + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + { + type: "userMessage", + id: "replayed-user", + clientId: null, + content: [{type: "text", text: "Earlier question", text_elements: []}], + }, + { + type: "agentMessage", + id: "replayed-reply", + text: "Earlier answer", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + ], + }; + client.setCodexResponse("thread/read", async () => ({ + thread: {...createThread(), turns: [replayedTurn]}, + })); + fireGoalTurnDuring(client, "model/list"); + + await client.request("session/load", {sessionId: secondSessionId, cwd, mcpServers: []}); + await settle(); + client.emit(turnCompletedFor(secondSessionId, secondTurnId)); + await settle(); + + const replayedTexts = client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "user_message_chunk" || update.sessionUpdate === "agent_message_chunk") + .map(update => (update as {content: {type: string, text?: string}}).content) + .map(content => content.type === "text" ? content.text : null); + + // "Earlier answer" (replay) must come from the replayed turn, before "Working on the + // goal" (the live goal turn fired during setup, via model/list). + const replayIndex = replayedTexts.findIndex(text => text === "Earlier answer"); + const liveIndex = replayedTexts.findIndex(text => text === "Working on the goal"); + expect(replayIndex).toBeGreaterThanOrEqual(0); + expect(liveIndex).toBeGreaterThan(replayIndex); + }); + + it('does not leak a subscription when thread/resume fails: a later resume of the same id works normally', async () => { + const client = await connectSession(2); + closeClient = () => client.connection.close(); + client.setCodexResponse("thread/resume", async () => { + throw new Error("simulated thread/resume failure"); + }); + + await expect(client.request("session/resume", {sessionId: secondSessionId, cwd})) + .rejects.toThrow(); + + client.setCodexResponse("thread/resume", async () => codexResponse("thread/resume")); + await client.request("session/resume", {sessionId: secondSessionId, cwd}); + const start = client.transcript.length; + + client.emit(turnStartedFor(secondSessionId, secondTurnId)); + await settle(); + client.emit(agentMessageDeltaFor(secondSessionId, secondTurnId, "Working on the goal")); + await settle(); + client.emit(turnCompletedFor(secondSessionId, secondTurnId)); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + expect(agentMessageChunks(transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/review-turn-ids.test.ts b/src/__tests__/CodexACPAgent/review-turn-ids.test.ts new file mode 100644 index 000000000..c5b06cf5c --- /dev/null +++ b/src/__tests__/CodexACPAgent/review-turn-ids.test.ts @@ -0,0 +1,401 @@ +import {describe, expect, it, vi} from "vitest"; +import * as acp from "@agentclientprotocol/sdk"; +import type {ServerNotification} from "../../app-server"; +import type {ErrorNotification, ReviewStartResponse} from "../../app-server/v2"; +import {createCodexMockTestFixture, createTestModel, type CodexMockTestFixture} from "../acp-test-utils"; +import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; + +// Codex answers `review/start` with the parent turn id and reports the review's items, errors and +// completion under it, but starts (and only accepts `turn/interrupt` for) a reviewer child turn. +const sessionId = "session-id"; +const parentTurnId = "review-parent-turn"; +const childTurnId = "review-child-turn"; + +const typedFailureCapabilities: acp.ClientCapabilities = { + _meta: {jetbrains: {air: {version: 1, capabilities: ["sessionFailure"]}}}, +}; + +const overloadedError: ErrorNotification["error"] = { + message: "Codex is temporarily overloaded.", + codexErrorInfo: "serverOverloaded", + additionalDetails: null, + misalignment: null, +}; + +const notGitRepositoryError: ErrorNotification["error"] = { + message: "/test/cwd is not a git repository", + codexErrorInfo: null, + additionalDetails: null, + misalignment: null, +}; + +describe("/review turn ids", () => { + it("tracks the parent id as the review turn and the child id as the interruptible one", async () => { + const review = await startReview(); + expect(review.sessionState.currentTurnId).toBe(parentTurnId); + expect(review.sessionState.interruptTurnId).toBe(childTurnId); + + review.send(agentMessageDelta("Looks good.")); + review.send(turnCompleted("completed")); + + const result = await review.prompt; + expect(result).toMatchObject({stopReason: "end_turn"}); + expect(review.sessionState.currentTurnId).toBeNull(); + expect(review.sessionState.interruptTurnId).toBeNull(); + await expect(review.dump(result)).toMatchFileSnapshot("data/review-turn-completed.json"); + }); + + it("surfaces a review error as agent text, like a normal turn's error", async () => { + const review = await startReview(); + + review.send(error(overloadedError)); + review.send(turnCompleted("failed", overloadedError)); + + const result = await review.prompt; + expect(result).toMatchObject({stopReason: "end_turn"}); + await expect(review.dump(result)).toMatchFileSnapshot("data/review-turn-error.json"); + }); + + it("fails the review prompt on a usage limit error", async () => { + const usageLimitError: ErrorNotification["error"] = { + message: "Usage limits were exceeded", + codexErrorInfo: "usageLimitExceeded", + additionalDetails: null, + misalignment: null, + }; + const review = await startReview(); + + review.send(error(usageLimitError)); + review.send(turnCompleted("failed", usageLimitError)); + + const failure = await review.prompt.then(() => null, (err: unknown) => err); + expect(failure).toBeInstanceOf(acp.RequestError); + expect(failure).toMatchObject({data: {message: "Usage limits were exceeded", codexErrorInfo: "usageLimitExceeded"}}); + }); + + it("returns a typed review failure once, on the prompt response only", async () => { + const review = await startReview(typedFailureCapabilities); + + review.send(error(overloadedError)); + review.send(turnCompleted("failed", overloadedError)); + + const result = await review.prompt; + expect(result).toMatchObject({ + stopReason: "end_turn", + _meta: {jetbrains: {air: {sessionFailure: { + id: `${parentTurnId}:error`, + category: "service", + severity: "error", + title: "Codex is temporarily overloaded.", + }}}}, + }); + // The terminal failure is not also published as a live session update. + await expect(review.dump(result)).toMatchFileSnapshot("data/review-turn-typed-failure.json"); + }); + + it("interrupts the child turn on session/cancel", async () => { + const review = await startReview(); + const turnInterrupt = vi.spyOn(review.fixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async () => { + review.send(turnCompleted("interrupted")); + }); + + await review.agent.cancel({sessionId}); + + expect(turnInterrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: childTurnId}); + await expect(review.prompt).resolves.toMatchObject({stopReason: "cancelled"}); + }); + + it("interrupts the child turn when the prompt request is cancelled", async () => { + const controller = new AbortController(); + const review = await startReview(undefined, controller.signal); + const turnInterrupt = vi.spyOn(review.fixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async () => { + review.send(turnCompleted("interrupted")); + }); + + controller.abort(); + + await expect(review.prompt).resolves.toMatchObject({stopReason: "cancelled"}); + await vi.waitFor(() => { + expect(turnInterrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: childTurnId}); + }); + }); + + it("interrupts with the review/start id until the child turn has started", async () => { + const review = await startReview(undefined, undefined, false); + const turnInterrupt = vi.spyOn(review.fixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async () => { + review.send(turnCompleted("interrupted")); + }); + + await review.agent.cancel({sessionId}); + + expect(turnInterrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: parentTurnId}); + await expect(review.prompt).resolves.toMatchObject({stopReason: "cancelled"}); + }); + + it("recomputes the turn id between retries once the child turn starts", async () => { + vi.useFakeTimers(); + try { + const review = await startReview(undefined, undefined, false); + const turnInterrupt = vi.spyOn(review.fixture.getCodexAcpClient(), "turnInterrupt") + .mockRejectedValueOnce(new Error("no active turn to interrupt")) + .mockImplementationOnce(async () => { + review.send(turnCompleted("interrupted")); + }); + + const cancelPromise = review.agent.cancel({sessionId}); + await vi.advanceTimersByTimeAsync(0); + expect(turnInterrupt).toHaveBeenCalledTimes(1); + expect(turnInterrupt).toHaveBeenNthCalledWith(1, {threadId: sessionId, turnId: parentTurnId}); + + // The child turn registers between the first (rejected) attempt and the retry. + review.send({method: "turn/started", params: {threadId: sessionId, turn: turn(childTurnId, "inProgress")}}); + await review.fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); + + await vi.advanceTimersByTimeAsync(25); + await cancelPromise; + expect(turnInterrupt).toHaveBeenCalledTimes(2); + expect(turnInterrupt).toHaveBeenNthCalledWith(2, {threadId: sessionId, turnId: childTurnId}); + await expect(review.prompt).resolves.toMatchObject({stopReason: "cancelled"}); + } finally { + vi.useRealTimers(); + } + }); + + it("retries with the turn id Codex reports as active on an 'expected active turn id' mismatch", async () => { + const review = await startReview(undefined, undefined, false); + const turnInterrupt = vi.spyOn(review.fixture.getCodexAcpClient(), "turnInterrupt") + .mockRejectedValueOnce(new Error(`expected active turn id ${parentTurnId} but found ${childTurnId}`)) + .mockImplementationOnce(async () => { + review.send(turnCompleted("interrupted")); + }); + + await review.agent.cancel({sessionId}); + + expect(turnInterrupt).toHaveBeenCalledTimes(2); + expect(turnInterrupt).toHaveBeenNthCalledWith(1, {threadId: sessionId, turnId: parentTurnId}); + expect(turnInterrupt).toHaveBeenNthCalledWith(2, {threadId: sessionId, turnId: childTurnId}); + await expect(review.prompt).resolves.toMatchObject({stopReason: "cancelled"}); + }); + + it("does not retry an 'expected active turn id' mismatch for a stale (already-completed) turn", async () => { + const review = await startReview(); + const turnInterrupt = vi.spyOn(review.fixture.getCodexAcpClient(), "turnInterrupt") + .mockRejectedValueOnce(new Error("expected active turn id some-other-turn but found unrelated-turn")); + + await review.agent.cancel({sessionId}); + + expect(turnInterrupt).toHaveBeenCalledTimes(1); + review.send(turnCompleted("completed")); + await expect(review.prompt).resolves.toMatchObject({stopReason: "end_turn"}); + }); + + it("ends the review prompt when Codex fails before starting the review", async () => { + const review = await startReview(undefined, undefined, false, false); + + // Codex sends nothing after this error: no turn/started and no turn/completed. + review.send(error(notGitRepositoryError)); + + const result = await review.prompt; + expect(result).toMatchObject({stopReason: "end_turn"}); + expect(review.sessionState.currentTurnId).toBeNull(); + expect(review.sessionState.interruptTurnId).toBeNull(); + await expect(review.dump(result)).toMatchFileSnapshot("data/review-unspawned-error.json"); + + const next = await runNextPrompt(review); + expect(next.result).toMatchObject({stopReason: "end_turn"}); + await expect(next.dump).toMatchFileSnapshot("data/review-unspawned-error-next-prompt.json"); + }); + + it("returns a typed failure when Codex fails before starting the review", async () => { + const review = await startReview(typedFailureCapabilities, undefined, false, false); + + review.send(error(notGitRepositoryError)); + + const result = await review.prompt; + expect(result).toMatchObject({ + stopReason: "end_turn", + _meta: {jetbrains: {air: {sessionFailure: {id: `${parentTurnId}:error`, severity: "error"}}}}, + }); + await expect(review.dump(result)).toMatchFileSnapshot("data/review-unspawned-typed-failure.json"); + + const next = await runNextPrompt(review); + expect(next.result).toMatchObject({stopReason: "end_turn"}); + await expect(next.dump).toMatchFileSnapshot("data/review-unspawned-typed-failure-next-prompt.json"); + }); + + it("still reports the next turn's own fatal error after an unspawned review", async () => { + const review = await startReview(undefined, undefined, false, false); + review.send(error(notGitRepositoryError)); + await review.prompt; + + const next = await runNextPrompt(review, notGitRepositoryError); + expect(next.result).toMatchObject({stopReason: "end_turn"}); + await expect(next.dump).toMatchFileSnapshot("data/review-unspawned-error-next-prompt-fails.json"); + }); + + it("keeps waiting for turn/completed after a fatal error once the review has started", async () => { + const review = await startReview(undefined, undefined, false); + let settled = false; + void review.prompt.then(() => settled = true, () => settled = true); + + review.send(error(overloadedError)); + await review.fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(settled).toBe(false); + + review.send(turnCompleted("failed", overloadedError)); + await expect(review.prompt).resolves.toMatchObject({stopReason: "end_turn"}); + }); +}); + +describe("normal turn ids", () => { + it("uses the one turn id for both completion and interrupt", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const sessionState = await createSession(fixture); + vi.spyOn(fixture.getCodexAppServerClient(), "turnStart").mockResolvedValue({turn: turn("turn-id", "inProgress")}); + const turnInterrupt = vi.spyOn(fixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async () => { + fixture.sendServerNotification({method: "turn/completed", params: {threadId: sessionId, turn: turn("turn-id", "interrupted")}}); + }); + + const prompt = agent.prompt({sessionId, prompt: [{type: "text", text: "hello"}]}); + await vi.waitFor(() => expect(sessionState.currentTurnId).toBe("turn-id")); + fixture.sendServerNotification({method: "turn/started", params: {threadId: sessionId, turn: turn("turn-id", "inProgress")}}); + await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); + expect(sessionState.interruptTurnId).toBe("turn-id"); + + await agent.cancel({sessionId}); + + expect(turnInterrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "turn-id"}); + await expect(prompt).resolves.toMatchObject({stopReason: "cancelled"}); + }); +}); + +async function startReview( + clientCapabilities?: acp.ClientCapabilities, + signal?: AbortSignal, + childStarted = true, + enteredReview = true, +): Promise<{ + fixture: CodexMockTestFixture, + agent: CodexAcpServer, + sessionState: SessionState, + prompt: Promise, + send: (notification: ServerNotification) => void, + dump: (response: acp.PromptResponse) => string, +}> { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + if (clientCapabilities) { + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities}); + } + const sessionState = await createSession(fixture); + const reviewStarted: ReviewStartResponse = {reviewThreadId: sessionId, turn: turn(parentTurnId, "inProgress")}; + vi.spyOn(fixture.getCodexAppServerClient(), "reviewStart").mockResolvedValue(reviewStarted); + + const prompt = agent.prompt({sessionId, prompt: [{type: "text", text: "/review"}]}, signal); + await vi.waitFor(() => expect(sessionState.currentTurnId).toBe(parentTurnId)); + const send = (notification: ServerNotification) => fixture.sendServerNotification(notification); + if (enteredReview) { + send({ + method: "item/started", + params: { + threadId: sessionId, + turnId: parentTurnId, + startedAtMs: 0, + item: {type: "enteredReviewMode", id: "entered-review", review: "current changes"}, + }, + }); + } + if (enteredReview && childStarted) { + send({method: "turn/started", params: {threadId: sessionId, turn: turn(childTurnId, "inProgress")}}); + } + await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); + return { + fixture, + agent, + sessionState, + prompt, + send, + dump: (response) => JSON.stringify({ + // The command list is published asynchronously after session/new; it is not part of the turn. + updates: fixture.getAcpConnectionEvents([]) + .map(event => event.args[0].update) + .filter(update => update?.sessionUpdate !== "available_commands_update"), + response, + }, null, 2), + }; +} + +// Codex reports the unfinished review's error again as the failure of the next turn. +async function runNextPrompt( + review: Awaited>, + ownError: ErrorNotification["error"] | null = null, +): Promise<{result: acp.PromptResponse, dump: string}> { + const {fixture, agent, sessionState, send} = review; + fixture.clearAcpConnectionDump(); + vi.spyOn(fixture.getCodexAppServerClient(), "turnStart").mockResolvedValue({turn: turn("next-turn", "inProgress")}); + const prompt = agent.prompt({sessionId, prompt: [{type: "text", text: "hello"}]}); + await vi.waitFor(() => expect(sessionState.currentTurnId).toBe("next-turn")); + send({method: "turn/started", params: {threadId: sessionId, turn: turn("next-turn", "inProgress")}}); + send({method: "item/agentMessage/delta", params: {threadId: sessionId, turnId: "next-turn", itemId: "next-message", delta: "Hi."}}); + if (ownError) { + send({method: "error", params: {threadId: sessionId, turnId: "next-turn", willRetry: false, error: ownError}}); + } + send({method: "turn/completed", params: {threadId: sessionId, turn: turn("next-turn", "failed", ownError ?? notGitRepositoryError)}}); + const result = await prompt; + return {result, dump: review.dump(result)}; +} + +async function createSession(fixture: CodexMockTestFixture): Promise { + const codexAcpClient = fixture.getCodexAcpClient(); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId, + currentModelId: "model-id[medium]", + models: [createTestModel()], + collaborationMode: "default", + currentServiceTier: null, + additionalDirectories: [], + }); + await fixture.getCodexAcpAgent().newSession({cwd: "/test/cwd", mcpServers: []}); + fixture.clearAcpConnectionDump(); + return fixture.getCodexAcpAgent().getSessionState(sessionId); +} + +function turn( + id: string, + status: "inProgress" | "completed" | "failed" | "interrupted", + turnError: ErrorNotification["error"] | null = null, +) { + return { + id, + items: [], + itemsView: "notLoaded" as const, + status, + error: turnError, + startedAt: null, + completedAt: null, + durationMs: null, + }; +} + +function turnCompleted( + status: "completed" | "failed" | "interrupted", + turnError: ErrorNotification["error"] | null = null, +): ServerNotification { + return {method: "turn/completed", params: {threadId: sessionId, turn: turn(parentTurnId, status, turnError)}}; +} + +function error(turnError: ErrorNotification["error"]): ServerNotification { + return {method: "error", params: {threadId: sessionId, turnId: parentTurnId, willRetry: false, error: turnError}}; +} + +function agentMessageDelta(delta: string): ServerNotification { + return {method: "item/agentMessage/delta", params: {threadId: sessionId, turnId: parentTurnId, itemId: "agent-message", delta}}; +} diff --git a/src/__tests__/CodexACPAgent/session-config-options-v2.test.ts b/src/__tests__/CodexACPAgent/session-config-options-v2.test.ts new file mode 100644 index 000000000..4b6679e47 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-config-options-v2.test.ts @@ -0,0 +1,185 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {ACPSessionConnection, AcpV2Connection} from '../../ACPSessionConnection'; +import {AgentMode, MODE_CONFIG_ID} from '../../AgentMode'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import {FAST_MODE_CONFIG_ID} from '../../FastModeConfig'; +import type {ReasoningEffortOption} from '../../app-server/v2'; +import {createTestModel, createTestSessionState} from '../acp-test-utils'; +import {createMockConnections} from './test-utils'; + +const lowEffort: ReasoningEffortOption = {reasoningEffort: "low", description: "Fast"}; +const mediumEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; + +function createSessionState() { + const fast = createTestModel({ + id: "fast-model", + displayName: "Fast model", + description: "Frontier", + supportedReasoningEfforts: [lowEffort, mediumEffort], + additionalSpeedTiers: ["fast"], + }); + const slow = createTestModel({id: "slow-model", displayName: "Slow model", isDefault: false}); + return createTestSessionState({ + sessionId: "session-1", + currentModelId: "fast-model[medium]", + availableModels: [fast, slow], + supportedReasoningEfforts: [lowEffort, mediumEffort], + currentModelSupportsFast: true, + }); +} + +/** + * Connects a v2 client through the router. Sessions can't be created over v2 yet, so the test + * registers session state on the agent directly. + */ +async function connectV2Client() { + const mocks = createMockConnections(); + let agent: CodexAcpServer | null = null; + let agentConnection: unknown = null; + const router = createAcpAgentRouter((connection) => { + agentConnection = connection; + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: true}); + agent = new CodexAcpServer(connection, codexAcpClient); + return agent; + }); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + const updates: unknown[] = []; + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => { + updates.push(ctx.params); + }) + .connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable)); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + }); + + const sessionState = createSessionState(); + // @ts-expect-error - register a session without a v2 `session/new` + agent!.sessions.set(sessionState.sessionId, sessionState); + if (!(agentConnection instanceof AcpV2Connection)) { + throw new Error("expected the router to hand the agent a v2 connection"); + } + return {connection, agent: agent!, sessionState, updates, view: agentConnection.extensionOnlyV1View()}; +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +describe('Session config options over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + }); + + it('builds the v2 config option list with configId and a boolean fast-mode option', async () => { + const {connection, agent, sessionState} = await connectV2Client(); + closeClient = () => connection.close(); + + // @ts-expect-error - private method, called directly to test the response shape in isolation + const response = agent.createSessionConfigOptionsResponseV2(sessionState); + + await expect(dump(response)).toMatchFileSnapshot('data/session-config-options-v2-list.json'); + }); + + it('sends config_option_update in the v2 shape', async () => { + const {connection, agent, sessionState, updates, view} = await connectV2Client(); + closeClient = () => connection.close(); + const session = new ACPSessionConnection(view, sessionState.sessionId); + + await session.update({ + sessionUpdate: "config_option_update", + configOptions: [ + // @ts-expect-error - the list the agent sends with every config_option_update + ...agent.createSessionConfigOptions(sessionState), + { + id: "grouped", + name: "Grouped", + type: "select", + currentValue: "a", + options: [{group: "letters", name: "Letters", options: [{value: "a", name: "A"}]}], + }, + ], + }); + + await vi.waitFor(() => expect(updates).toHaveLength(1)); + await expect(dump(updates)).toMatchFileSnapshot('data/session-config-options-v2-update.json'); + }); + + it('rejects current_mode_update on v2', async () => { + const {connection, updates, view} = await connectV2Client(); + closeClient = () => connection.close(); + const session = new ACPSessionConnection(view, "session-1"); + + await expect(session.update({sessionUpdate: "current_mode_update", currentModeId: "agent"})) + .rejects.toThrow("'current_mode_update' session update does not exist in ACP v2"); + expect(updates).toEqual([]); + }); + + it('applies session/set_config_option and answers with v2 config options', async () => { + const {connection, sessionState} = await connectV2Client(); + closeClient = () => connection.close(); + + const fastModeResponse = await connection.agent.request(acpV2.methods.agent.session.setConfigOption, { + sessionId: sessionState.sessionId, + configId: FAST_MODE_CONFIG_ID, + type: "boolean", + value: true, + }); + const modeResponse = await connection.agent.request(acpV2.methods.agent.session.setConfigOption, { + sessionId: sessionState.sessionId, + configId: MODE_CONFIG_ID, + type: "id", + value: AgentMode.ReadOnly.id, + }); + + expect(sessionState.fastModeEnabled).toBe(true); + expect(sessionState.agentMode).toBe(AgentMode.ReadOnly); + await expect(dump({fastModeResponse, modeResponse})) + .toMatchFileSnapshot('data/session-config-options-v2-set.json'); + }); + + it('rejects a set_config_option value that does not match its type', async () => { + const {connection, sessionState} = await connectV2Client(); + closeClient = () => connection.close(); + + await expect(connection.agent.request(acpV2.methods.agent.session.setConfigOption, { + sessionId: sessionState.sessionId, + configId: MODE_CONFIG_ID, + type: "boolean", + value: true, + })).rejects.toMatchObject({code: -32602}); + await expect(connection.agent.request(acpV2.methods.agent.session.setConfigOption, { + sessionId: sessionState.sessionId, + configId: FAST_MODE_CONFIG_ID, + type: "_custom", + value: true, + })).rejects.toMatchObject({code: -32602}); + expect(sessionState.agentMode).toBe(AgentMode.DEFAULT_AGENT_MODE); + expect(sessionState.fastModeEnabled).toBe(false); + }); + + it('does not register session/set_mode on v2', async () => { + const {connection, sessionState} = await connectV2Client(); + closeClient = () => connection.close(); + + await expect(connection.agent.request("session/set_mode", { + sessionId: sessionState.sessionId, + modeId: AgentMode.ReadOnly.id, + })).rejects.toMatchObject({code: -32601}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-fork.test.ts b/src/__tests__/CodexACPAgent/session-fork.test.ts index ed4dc02ad..b800681f0 100644 --- a/src/__tests__/CodexACPAgent/session-fork.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork.test.ts @@ -43,6 +43,8 @@ describe("ACP session fork", () => { cwd: "/workspace", mcpServers: [], }, expect.any(Function)); + // Regression pin: v1 must keep sending `modes` (v2 drops it via `forkSessionV2`). + expect(response.modes).toBeDefined(); }); it("waits for MCP startup before completing session fork", async () => { diff --git a/src/__tests__/CodexACPAgent/session-goal-v2.test.ts b/src/__tests__/CodexACPAgent/session-goal-v2.test.ts new file mode 100644 index 000000000..7c4c52818 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-goal-v2.test.ts @@ -0,0 +1,135 @@ +import {afterEach, describe, expect, it} from 'vitest'; +import {GOAL_CONTROL_METHOD} from '../../AcpExtensions'; +import { + sessionId, + connectSession, + turnStarted, + turnCompleted, + agentMessageDelta, + settle, + stateUpdates, + type PromptSession, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; +import type {ThreadGoal} from '../../app-server/v2'; + +function createThreadGoal(overrides?: Partial): ThreadGoal { + return { + threadId: sessionId, + objective: "Ship it", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +function userMessageChunks(transcript: PromptSession['transcript']) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "user_message_chunk"); +} + +function agentMessageChunks(transcript: PromptSession['transcript']) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "agent_message_chunk"); +} + +describe('_session/goal on ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + expectConformingV2SessionUpdates(); + }); + + it('sends running/idle and renders the Codex-routed turn for a v2 goal set, with no user_message, then resolves', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const goal = createThreadGoal(); + client.setCodexResponse("thread/goal/set", async () => ({goal})); + const start = client.transcript.length; + + const goalRequest = client.request(GOAL_CONTROL_METHOD, { + sessionId, action: "set", objective: goal.objective, + }); + await settle(); + client.emit({method: "thread/goal/updated", params: {threadId: sessionId, turnId: null, goal}}); + await settle(); + client.emit(turnStarted()); + await settle(); + client.emit(agentMessageDelta("Working on the goal")); + await settle(); + client.emit(turnCompleted()); + + await expect(goalRequest).resolves.toEqual({}); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + expect(userMessageChunks(transcript)).toEqual([]); + expect(agentMessageChunks(transcript)).toEqual([ + expect.objectContaining({sessionUpdate: "agent_message_chunk"}), + ]); + }); + + it('pauses a goal and returns {}, matching v1', async () => { + const goal = createThreadGoal({status: "paused"}); + for (const protocolVersion of [1, 2] as const) { + const client = await connectSession(protocolVersion); + closeClient = () => client.connection.close(); + client.setCodexResponse("thread/goal/set", async () => ({goal})); + + const pauseRequest = client.request(GOAL_CONTROL_METHOD, {sessionId, action: "pause"}); + await settle(); + client.emit({method: "thread/goal/updated", params: {threadId: sessionId, turnId: null, goal}}); + + await expect(pauseRequest).resolves.toEqual({}); + client.connection.close(); + closeClient = null; + } + }); + + it('clears a goal and returns {}, matching v1', async () => { + for (const protocolVersion of [1, 2] as const) { + const client = await connectSession(protocolVersion); + closeClient = () => client.connection.close(); + // `cleared: false` (already cleared) skips `runGoalClear`'s wait for a matching + // `thread/goal/cleared` notification, which this test doesn't need to exercise. + client.setCodexResponse("thread/goal/clear", async () => ({cleared: false})); + + await expect(client.request(GOAL_CONTROL_METHOD, {sessionId, action: "clear"})).resolves.toEqual({}); + client.connection.close(); + closeClient = null; + } + }); + + it('rejects an unknown session with invalid_params, matching v1', async () => { + for (const protocolVersion of [1, 2] as const) { + const client = await connectSession(protocolVersion); + closeClient = () => client.connection.close(); + + await expect(client.request(GOAL_CONTROL_METHOD, { + sessionId: "no-such-session", action: "clear", + })).rejects.toMatchObject({code: -32602}); + client.connection.close(); + closeClient = null; + } + }); + + it('rejects a blank objective with invalid_params, matching v1', async () => { + for (const protocolVersion of [1, 2] as const) { + const client = await connectSession(protocolVersion); + closeClient = () => client.connection.close(); + + await expect(client.request(GOAL_CONTROL_METHOD, { + sessionId, action: "set", objective: " ", + })).rejects.toMatchObject({code: -32602}); + client.connection.close(); + closeClient = null; + } + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-lifecycle-v2.test.ts b/src/__tests__/CodexACPAgent/session-lifecycle-v2.test.ts new file mode 100644 index 000000000..3515676a4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-lifecycle-v2.test.ts @@ -0,0 +1,420 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import type {Thread} from '../../app-server/v2'; +import {createTestModel} from '../acp-test-utils'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +const sessionId = "thread-1"; +const forkedSessionId = "fork-1"; +const cwd = "/workspace"; + +function createThread(overrides?: Partial): Thread { + return { + id: sessionId, + sessionId, + parentThreadId: null, + threadSource: null, + originator: null, + forkedFromId: null, + preview: "Earlier session", + ephemeral: false, + modelProvider: "openai", + model: null, + reasoningEffort: null, + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd, + cliVersion: "0.0.0", + section: null, + sectionEnteredAt: null, + projectId: null, + historyMode: "legacy", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + ...overrides, + }; +} + +/** Canned Codex app-server responses, keyed by method. */ +function codexResponse(method: string, replayTurns: Thread["turns"] = []): unknown { + switch (method) { + case "thread/start": + case "thread/resume": + return { + thread: createThread(), + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + turnsBackwardsCursor: null, + }; + case "thread/fork": + return { + thread: createThread({id: forkedSessionId, sessionId: forkedSessionId}), + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + }; + case "thread/read": + // `threadReadWithHistory` reads twice for a "legacy" history-mode thread (once to + // learn the history mode, once with `includeTurns: true`); returning the full + // history from both is harmless since only the second response is used. + return {thread: createThread({turns: replayTurns})}; + case "model/list": + return {data: [createTestModel({id: "gpt-5"})], nextCursor: null}; + case "skills/list": + return {data: []}; + case "config/read": + return {config: {}, origins: {}, layers: []}; + case "thread/list": + return {data: [createThread()], nextCursor: null}; + case "thread/goal/get": + return {goal: null}; + default: + return {}; + } +} + +/** Methods whose requests the snapshots record; the rest is session-setup noise. */ +const recordedCodexMethods = new Set([ + "thread/start", + "thread/resume", + "thread/fork", + "thread/list", + "thread/unsubscribe", + "thread/archive", +]); + +/** Connects a v2 client to the agent through the router, over a mocked Codex app-server. */ +async function connectV2Client(options?: { + replayTurns?: Thread["turns"]; + /** Makes the given Codex app-server method reject, to simulate a Codex-side failure. */ + failMethod?: {method: string; error: Error}; +}) { + const mocks = createMockConnections(); + const codexRequests: Array<{method: string, params: unknown}> = []; + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string, params?: unknown) => { + if (recordedCodexMethods.has(method)) { + codexRequests.push({method, params}); + } + if (options?.failMethod?.method === method) { + throw options.failMethod.error; + } + return codexResponse(method, options?.replayTurns); + }); + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockResolvedValue({ready: [], failed: [], cancelled: []}); + let agent: CodexAcpServer | null = null; + const router = createAcpAgentRouter((connection) => { + agent = new CodexAcpServer(connection, codexAcpClient); + return agent; + }); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + const updates: acpV2.UpdateSessionNotification[] = []; + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => { + checkV2SessionUpdate(ctx.params.update); + updates.push(ctx.params); + }) + .connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable)); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + }); + return {connection, agent: () => agent!, codexRequests, updates}; +} + +/** + * Waits for the updates a session publishes on its own after `session/new` or `session/resume`, + * and returns them in a stable order (they are published concurrently). + */ +async function waitForSessionUpdates( + updates: acpV2.UpdateSessionNotification[], + expected: Array, +) { + await vi.waitFor(() => expect(updates.map(({update}) => update.sessionUpdate).sort()).toEqual(expected)); + return [...updates].sort((a, b) => a.update.sessionUpdate.localeCompare(b.update.sessionUpdate)); +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +describe('Session lifecycle over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('creates a session without mcpServers and answers with sessionId and configOptions only', async () => { + const {connection, codexRequests, updates} = await connectV2Client(); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.new, {cwd}); + const sessionUpdates = await waitForSessionUpdates(updates, ["available_commands_update"]); + + await expect(dump({response, codexRequests, updates: sessionUpdates})) + .toMatchFileSnapshot('data/session-lifecycle-v2-new.json'); + }); + + it('creates a session with v2 mcpServers', async () => { + const {connection, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.new, { + cwd, + mcpServers: [ + {type: "stdio", name: "files", command: "/usr/local/bin/mcp-fs", args: ["--root", cwd]}, + {type: "http", name: "remote", url: "https://example.com/mcp"}, + ], + }); + + expect(response.sessionId).toBe(sessionId); + await expect(dump(codexRequests)).toMatchFileSnapshot('data/session-lifecycle-v2-new-mcp-servers.json'); + }); + + it('lists sessions', async () => { + const {connection, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.list, {cwd}); + + await expect(dump({response, codexRequests})).toMatchFileSnapshot('data/session-lifecycle-v2-list.json'); + }); + + it('closes a session', async () => { + const {connection, agent, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + await connection.agent.request(acpV2.methods.agent.session.new, {cwd}); + codexRequests.splice(0); + + const response = await connection.agent.request(acpV2.methods.agent.session.close, {sessionId}); + + expect(() => agent().getSessionState(sessionId)).toThrow(`Session ${sessionId} not found`); + await expect(dump({response, codexRequests})).toMatchFileSnapshot('data/session-lifecycle-v2-close.json'); + }); + + it('deletes a session', async () => { + const {connection, agent, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + await connection.agent.request(acpV2.methods.agent.session.new, {cwd}); + codexRequests.splice(0); + + const response = await connection.agent.request(acpV2.methods.agent.session.delete, {sessionId}); + + expect(() => agent().getSessionState(sessionId)).toThrow(`Session ${sessionId} not found`); + await expect(dump({response, codexRequests})).toMatchFileSnapshot('data/session-lifecycle-v2-delete.json'); + }); + + it('resumes a session without replayFrom and answers with configOptions only', async () => { + const {connection, codexRequests, updates} = await connectV2Client(); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.resume, {sessionId, cwd}); + // Only the command list; no history is replayed. The current goal is AIR-only. + const sessionUpdates = await waitForSessionUpdates(updates, ["available_commands_update"]); + + await expect(dump({response, codexRequests, updates: sessionUpdates})) + .toMatchFileSnapshot('data/session-lifecycle-v2-resume.json'); + }); + + it('resumes a session with a null replayFrom the same way', async () => { + const {connection, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: null, + }); + + await expect(dump({response, codexRequests})) + .toMatchFileSnapshot('data/session-lifecycle-v2-resume-null-replay.json'); + }); + + it('rejects resume with an unknown replayFrom type', async () => { + const {connection, agent, codexRequests} = await connectV2Client(); + closeClient = () => connection.close(); + + const error = await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: {type: "_checkpoint", checkpointId: "c-1"}, + }).then(() => null, (err) => ({code: err.code, message: err.message, data: err.data})); + + expect(codexRequests).toEqual([]); + expect(() => agent().getSessionState(sessionId)).toThrow(`Session ${sessionId} not found`); + await expect(dump(error)).toMatchFileSnapshot('data/session-lifecycle-v2-resume-unknown-replay.json'); + }); + + /** A turn with a prompt-inserted user message, a legacy one, an agent message and a tool call. */ + const replayTurns: Thread["turns"] = [{ + id: "turn-1", + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + { + type: "userMessage", + id: "item-user-1", + clientId: "client-user-1", + content: [{type: "text", text: "Inserted via session/prompt", text_elements: []}], + }, + { + type: "userMessage", + id: "item-user-2", + clientId: null, + content: [{type: "text", text: "A legacy message with no clientId", text_elements: []}], + }, + { + type: "agentMessage", + id: "item-agent-1", + text: "Hello!", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + { + type: "commandExecution", + id: "item-cmd-1", + pluginId: null, + scriptPath: null, + command: "ls", + cwd, + processId: null, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "README.md\n", + exitCode: 0, + durationMs: 5, + }, + ], + }]; + + it('replays history before answering resume with replayFrom start', async () => { + const {connection, agent, codexRequests, updates} = await connectV2Client({replayTurns}); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: {type: "start"}, + }); + + // Every replayed update must have already arrived by the time the response + // resolves; nothing should still be pending afterwards. + const replayedUpdates = [...updates]; + expect(agent().getSessionState(sessionId)).toBeTruthy(); + + await expect(dump({response, codexRequests, updates: replayedUpdates})) + .toMatchFileSnapshot('data/session-lifecycle-v2-resume-start-replay.json'); + }); + + it('does not replay any history when replayFrom is absent', async () => { + const {connection, updates} = await connectV2Client({replayTurns}); + closeClient = () => connection.close(); + + await connection.agent.request(acpV2.methods.agent.session.resume, {sessionId, cwd}); + await waitForSessionUpdates(updates, ["available_commands_update"]); + + expect(updates.some(({update}) => update.sessionUpdate.endsWith("_message_chunk") + || update.sessionUpdate.endsWith("_message") + || update.sessionUpdate === "tool_call")).toBe(false); + }); + + it('forks a session, dropping modes and using v2-shaped configOptions', async () => { + const {connection, codexRequests, updates} = await connectV2Client(); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.fork, { + sessionId, + cwd, + mcpServers: [], + }); + + expect("modes" in response).toBe(false); + expect(Array.isArray(response.configOptions)).toBe(true); + // Same parity as v1: forking publishes no `available_commands_update`. + expect(updates.some(({update}) => update.sessionUpdate === "available_commands_update")).toBe(false); + await expect(dump({response, codexRequests})).toMatchFileSnapshot('data/session-lifecycle-v2-fork.json'); + }); + + it('installs the forked session so it is immediately usable', async () => { + const {connection, agent} = await connectV2Client(); + closeClient = () => connection.close(); + + const response = await connection.agent.request(acpV2.methods.agent.session.fork, { + sessionId, + cwd, + mcpServers: [], + }); + + expect(agent().getSessionState(response.sessionId)).toBeTruthy(); + }); + + it('rejects fork with an unresolved AIR fork point, same as v1', async () => { + const {connection} = await connectV2Client(); + closeClient = () => connection.close(); + + const error = await connection.agent.request(acpV2.methods.agent.session.fork, { + sessionId, + cwd, + mcpServers: [], + _meta: {jetbrains: {air: {fork: {version: 1, messageId: "missing-item"}}}}, + }).then(() => null, (err) => ({code: err.code, message: err.message})); + + await expect(dump(error)).toMatchFileSnapshot('data/session-lifecycle-v2-fork-invalid-params.json'); + }); + + it('rejects fork of an unknown session, same as v1', async () => { + const {connection} = await connectV2Client({ + failMethod: {method: "thread/fork", error: new Error("Session not found: unknown-source")}, + }); + closeClient = () => connection.close(); + + const error = await connection.agent.request(acpV2.methods.agent.session.fork, { + sessionId: "unknown-source", + cwd, + mcpServers: [], + }).then(() => null, (err) => ({code: err.code, message: err.message})); + + await expect(dump(error)).toMatchFileSnapshot('data/session-lifecycle-v2-fork-unknown-session.json'); + }); + + it('does not register session/load on v2', async () => { + const {connection} = await connectV2Client(); + closeClient = () => connection.close(); + + await expect(connection.agent.request("session/load", {sessionId, cwd, mcpServers: []})) + .rejects.toMatchObject({code: -32601}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-update-v2.test.ts b/src/__tests__/CodexACPAgent/session-update-v2.test.ts new file mode 100644 index 000000000..4d3732046 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-update-v2.test.ts @@ -0,0 +1,200 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {type AcpClientConnection, ACPSessionConnection, AcpV2Connection} from '../../ACPSessionConnection'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +const usageUpdate: acp.SessionUpdate = { + sessionUpdate: "usage_update", + used: 1200, + size: 200000, + cost: {amount: 0.25, currency: "USD"}, +}; + +const sessionInfoUpdate: acp.SessionUpdate = { + sessionUpdate: "session_info_update", + title: "Fix the build", +}; + +/** Connects a router and exposes the agent-side connection handle the router hands to the agent. */ +function connectRouter() { + const mocks = createMockConnections(); + let agentConnection: AcpClientConnection | AcpV2Connection | null = null; + const router = createAcpAgentRouter((connection) => { + agentConnection = connection; + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: true}); + return new CodexAcpServer(connection, codexAcpClient); + }); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + return { + clientStream: acp.ndJsonStream(clientToAgent.writable, agentToClient.readable), + agentConnection: () => agentConnection!, + }; +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +describe('ACPSessionConnection - session/update over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + async function connectV2Client() { + const {clientStream, agentConnection} = connectRouter(); + const received: unknown[] = []; + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => { + checkV2SessionUpdate(ctx.params.update); + received.push(ctx.params); + }) + .connect(clientStream); + closeClient = () => connection.close(); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + }); + const handle = agentConnection(); + if (!(handle instanceof AcpV2Connection)) { + throw new Error("expected the router to hand the agent a v2 connection"); + } + return {received, view: handle.extensionOnlyV1View()}; + } + + it('delivers pass-through updates to a v2 client through the v2 binding', async () => { + const {received, view} = await connectV2Client(); + const session = new ACPSessionConnection(view, "session-1"); + + expect(session.protocolVersion).toBe(2); + await session.update(usageUpdate); + // Code that still calls `notify` on the connection directly takes the same v2 path. + await view.notify(acp.methods.client.session.update, {sessionId: "session-1", update: sessionInfoUpdate}); + + await vi.waitFor(() => expect(received).toHaveLength(2)); + await expect(dump(received)).toMatchFileSnapshot('data/session-update-v2-pass-through.json'); + }); + + it('renders tool calls and agent chunks in the v2 shape', async () => { + const {received, view} = await connectV2Client(); + const session = new ACPSessionConnection(view, "session-1"); + + await session.update({sessionUpdate: "tool_call", toolCallId: "call-1", title: "Run tests", kind: "execute", status: "pending"}); + await session.update({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + content: [{type: "content", content: {type: "text", text: "ok"}}], + }); + await session.update({sessionUpdate: "tool_call_update", toolCallId: "call-1", content: null, locations: null}); + await session.update({sessionUpdate: "agent_message_chunk", messageId: "item-1", content: {type: "text", text: "hi"}}); + + await vi.waitFor(() => expect(received).toHaveLength(4)); + await expect(dump(received)).toMatchFileSnapshot('data/session-update-v2-tool-calls-and-chunks.json'); + }); + + it('renders the subagent RFD upsert and async task updates under `_`-prefixed tags', async () => { + const {received, view} = await connectV2Client(); + const session = new ACPSessionConnection(view, "session-1"); + + await session.update({ + sessionUpdate: "subagent_spawned", + subagentSessionId: "child-1", + name: "Researcher", + task: "Look into flaky tests", + capabilities: {}, + }); + await session.update({sessionUpdate: "subagent_state_update", subagentSessionId: "child-1", state: "completed"}); + await session.update({ + sessionUpdate: "async_task_spawned", + asyncTaskId: "task-1", + name: "npm test", + taskType: "shell", + showInTranscript: false, + canStop: true, + toolCallId: "call-1", + }); + await session.update({ + sessionUpdate: "async_task_state_update", + asyncTaskId: "task-1", + state: "completed", + toolCallId: "call-1", + }); + + await vi.waitFor(() => expect(received).toHaveLength(4)); + await expect(dump(received)).toMatchFileSnapshot('data/session-update-v2-subagent-and-async-task.json'); + }); + + it('fails loudly for updates whose v2 shape belongs to a later topic', async () => { + const {received, view} = await connectV2Client(); + const session = new ACPSessionConnection(view, "session-1"); + + await expect(session.update({ + sessionUpdate: "tool_call", + toolCallId: "call-1", + title: "Edit a.ts", + content: [{type: "diff", path: "/workspace/a.ts", oldText: "a", newText: "b"}], + })).rejects.toThrow("Malformed 'diff_git_patch' diff metadata"); + await expect(view.notify(acp.methods.client.session.update, { + sessionId: "session-1", + update: {sessionUpdate: "tool_call_update", toolCallId: "call-2", _meta: {terminal_exit: {exit_code: 0}}}, + })).rejects.toThrow("Malformed 'terminal_exit' tool call metadata"); + await expect(session.update({ + sessionUpdate: "user_message_chunk", + content: {type: "text", text: "hi"}, + })).rejects.toThrow("'user_message_chunk' session update without a messageId is not supported on an ACP v2 connection"); + + // A later pass-through update is the first one the client sees. + await session.update(usageUpdate); + await vi.waitFor(() => expect(received).toHaveLength(1)); + expect(received).toEqual([{sessionId: "session-1", update: usageUpdate}]); + }); + + it('keeps rejecting standard methods with no v2 send path yet', async () => { + const {view} = await connectV2Client(); + + // `session/request_permission` now has a v2 send path (see permissions-v2.test.ts). + await expect(view.request(acp.methods.client.fs.readTextFile, { + sessionId: "session-1", + path: "/workspace/a.ts", + })).rejects.toThrow("'fs/read_text_file' is not supported on an ACP v2 connection yet"); + }); + + it('sends v1 updates unchanged over a v1 connection', async () => { + const {clientStream, agentConnection} = connectRouter(); + const received: unknown[] = []; + const connection = acp.client({name: "test-client"}) + .onNotification(acp.methods.client.session.update, (ctx) => { + received.push(ctx.params); + }) + .connect(clientStream); + closeClient = () => connection.close(); + await connection.agent.request(acp.methods.agent.initialize, {protocolVersion: acp.PROTOCOL_VERSION}); + const handle = agentConnection(); + if (handle instanceof AcpV2Connection) { + throw new Error("expected the router to hand the agent a v1 connection"); + } + const session = new ACPSessionConnection(handle, "session-1"); + + expect(session.protocolVersion).toBe(1); + await session.update({sessionUpdate: "tool_call", toolCallId: "call-1", title: "Run tests"}); + await session.update(usageUpdate); + + await vi.waitFor(() => expect(received).toHaveLength(2)); + await expect(dump(received)).toMatchFileSnapshot('data/session-update-v1-routed.json'); + }); +}); diff --git a/src/__tests__/CodexACPAgent/steer-events.test.ts b/src/__tests__/CodexACPAgent/steer-events.test.ts index c13a719be..3e9a2e0a1 100644 --- a/src/__tests__/CodexACPAgent/steer-events.test.ts +++ b/src/__tests__/CodexACPAgent/steer-events.test.ts @@ -67,10 +67,13 @@ describe('_session/steering', () => { prompt: [{type: "text", text: "also keep backward compatibility"}], })).resolves.toEqual({outcome: "injected"}); + // Every steer mints a fresh `clientUserMessageId` (Codex-side param only; no ACP wire + // change on v1). expect(turnSteerSpy).toHaveBeenCalledWith({ threadId: "session-id", expectedTurnId: "turn-id", input: [{type: "text", text: "also keep backward compatibility", text_elements: []}], + clientUserMessageId: expect.any(String), }); turnCompleted.resolve({ @@ -181,6 +184,7 @@ describe('_session/steering', () => { threadId: "session-id", expectedTurnId: "new-turn-id", input: [{type: "text", text: "second late follow-up", text_elements: []}], + clientUserMessageId: expect.any(String), }); turnCompleted.resolve({ diff --git a/src/__tests__/CodexACPAgent/steering-v2.test.ts b/src/__tests__/CodexACPAgent/steering-v2.test.ts new file mode 100644 index 000000000..96c8e5c16 --- /dev/null +++ b/src/__tests__/CodexACPAgent/steering-v2.test.ts @@ -0,0 +1,226 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {SESSION_STEERING_METHOD} from '../../AcpExtensions'; +import { + sessionId, + connectSession, + userMessageItem, + itemStarted, + itemCompleted, + turnStarted, + turnCompleted, + turnFinished, + settle, + stateUpdates, + dump, + type PromptSession, + type TranscriptEntry, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +function userMessageChunks(transcript: TranscriptEntry[]) { + return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => update.sessionUpdate === "user_message_chunk"); +} + +/** Overrides `turn/steer` to capture the minted `clientUserMessageId` it was sent. */ +function captureSteerClientId(client: PromptSession): {captured: () => string} { + let captured: string | null = null; + client.setCodexResponse("turn/steer", async (params) => { + captured = (params as {clientUserMessageId: string}).clientUserMessageId; + return {}; + }); + return { + captured: () => { + if (captured === null) { + throw new Error("turn/steer was not called yet"); + } + return captured; + }, + }; +} + +/** Runs a normal prompt to completion, so `v2PromptsInFlight` no longer owns the session. */ +async function runPromptToCompletion(client: PromptSession) { + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); +} + +describe('_session/steering over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('shows a live user_message, with no extra states, when a steer injected into a v2-prompt-owned turn lands', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + const steerClientId = captureSteerClientId(client); + + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const promptClientId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(promptClientId))); + const {messageId} = await response; + const start = client.transcript.length; + + const steerResult = await client.request(SESSION_STEERING_METHOD, { + sessionId, + prompt: [{type: "text", text: "also do this"}], + }); + expect(steerResult).toEqual({outcome: "injected"}); + // The response is unchanged (no messageId); the user message appears only once landed. + expect(userMessageChunks(client.transcript.slice(start))).toEqual([]); + + client.emit(itemStarted(userMessageItem(steerClientId.captured(), "also do this"))); + await settle(); + expect(userMessageChunks(client.transcript.slice(start))).toEqual([ + {sessionUpdate: "user_message_chunk", messageId: steerClientId.captured(), content: {type: "text", text: "also do this"}}, + ]); + + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + // Steering an already-owned turn sends no states of its own. + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, messageId).replaceAll(steerClientId.captured(), "")) + .toMatchFileSnapshot('data/steering-v2-injected-owned-turn.json'); + }); + + it('shows a live user_message when a steer injected into an unowned turn lands', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + await runPromptToCompletion(client); + const steerClientId = captureSteerClientId(client); + + const start = client.transcript.length; + client.emit(turnStarted("turn-2")); + await settle(); + + const steerResult = await client.request(SESSION_STEERING_METHOD, { + sessionId, + prompt: [{type: "text", text: "steer the goal turn"}], + }); + expect(steerResult).toEqual({outcome: "injected"}); + + client.emit(itemCompleted(userMessageItem(steerClientId.captured(), "steer the goal turn"), "turn-2")); + client.emit(turnCompleted("turn-2")); + await settle(); + + const transcript = client.transcript.slice(start); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + expect(userMessageChunks(transcript)).toEqual([ + {sessionUpdate: "user_message_chunk", messageId: steerClientId.captured(), content: {type: "text", text: "steer the goal turn"}}, + ]); + }); + + it('emits nothing when the turn is interrupted before the steer lands', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + await runPromptToCompletion(client); + captureSteerClientId(client); + + const start = client.transcript.length; + client.emit(turnStarted("turn-2")); + await settle(); + + const steerResult = await client.request(SESSION_STEERING_METHOD, { + sessionId, + prompt: [{type: "text", text: "never lands"}], + }); + expect(steerResult).toEqual({outcome: "injected"}); + + // Codex drops the steered input silently: the turn ends with no matching userMessage item. + client.emit(turnFinished("interrupted", "turn-2")); + await settle(); + + const transcript = client.transcript.slice(start); + expect(userMessageChunks(transcript)).toEqual([]); + expect(stateUpdates(transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "cancelled"}]); + }); + + it('starts a new turn with running/user_message/idle when there is nothing to steer', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const steerPromise = client.request(SESSION_STEERING_METHOD, { + sessionId, + prompt: [{type: "text", text: "start fresh"}], + }); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + expect(clientUserMessageId).toEqual(expect.any(String)); + client.emit(turnStarted()); + const steerResult = await steerPromise; + expect(steerResult).toEqual({outcome: "startedNewTurn"}); + + await settle(); + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}]); + + client.emit(itemCompleted(userMessageItem(clientUserMessageId, "start fresh"))); + await settle(); + expect(userMessageChunks(client.transcript)).toEqual([ + {sessionUpdate: "user_message_chunk", messageId: clientUserMessageId, content: {type: "text", text: "start fresh"}}, + ]); + + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([{state: "running"}, {state: "idle", stopReason: "end_turn"}]); + await expect(dump(client.transcript, clientUserMessageId)).toMatchFileSnapshot('data/steering-v2-fallback-new-turn.json'); + }); + + it('queues a v2 prompt behind the steering fallback\'s new turn', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + + const steerPromise = client.request(SESSION_STEERING_METHOD, { + sessionId, + prompt: [{type: "text", text: "start fresh"}], + }); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const steerClientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + await steerPromise; + + // A v2 prompt arriving while the fallback turn runs must queue, not start its own turn/start. + const bResponse = client.sendPrompt([{type: "text", text: "B"}]); + await settle(); + expect(client.turnStartParams).toHaveLength(1); + + client.emit(itemCompleted(userMessageItem(steerClientUserMessageId, "start fresh"))); + client.emit(turnCompleted()); + await client.promptRunFinished(0); + await settle(); + + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(2)); + const bClientUserMessageId = client.turnStartParams[1]!["clientUserMessageId"] as string; + client.emit(turnStarted("turn-2")); + client.emit(itemCompleted(userMessageItem(bClientUserMessageId, "B"), "turn-2")); + const {messageId: bMessageId} = await bResponse; + expect(bMessageId).toBe(bClientUserMessageId); + client.emit(turnCompleted("turn-2")); + await client.promptRunFinished(1); + await settle(); + + expect(stateUpdates(client.transcript)).toEqual([ + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + {state: "running"}, + {state: "idle", stopReason: "end_turn"}, + ]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/subagent-and-async-task-updates-v2.test.ts b/src/__tests__/CodexACPAgent/subagent-and-async-task-updates-v2.test.ts new file mode 100644 index 000000000..8c433b314 --- /dev/null +++ b/src/__tests__/CodexACPAgent/subagent-and-async-task-updates-v2.test.ts @@ -0,0 +1,194 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {ACPSessionConnection, AcpV2Connection} from '../../ACPSessionConnection'; +import {CodexSubagentEventRouter} from '../../subagents/CodexSubagentEventRouter'; +import {CodexBackgroundTerminalTasks} from '../../async-tasks/CodexBackgroundTerminalTasks'; +import type {CodexAppServerClient} from '../../CodexAppServerClient'; +import type {ThreadBackgroundTerminalsListResponse} from '../../async-tasks/BackgroundTerminalApi'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +/** A v2-wrapped `ACPSessionConnection`, so `session.update()` renders through `toV2SessionUpdate`. */ +function v2Session(sessionId: string): {session: ACPSessionConnection; updates: acpV2.SessionUpdate[]} { + const updates: acpV2.SessionUpdate[] = []; + const v2Client = { + notify: vi.fn(async (_method: string, params: unknown) => { + const update = (params as {update: acpV2.SessionUpdate}).update; + checkV2SessionUpdate(update); + updates.push(update); + }), + request: vi.fn(), + }; + const view = new AcpV2Connection(v2Client as any).extensionOnlyV1View(); + return {session: new ACPSessionConnection(view, sessionId), updates}; +} + +describe('subagent and async task updates on ACP v2', () => { + afterEach(() => { + expectConformingV2SessionUpdates(); + }); + + it('renders the subagent lifecycle as `_subagent_update` when native subagents are enabled', async () => { + const {session, updates} = v2Session("root"); + const router = new CodexSubagentEventRouter("root", true, session, () => {}); + + await router.handle({ + method: "item/started", + params: { + threadId: "root", + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "subAgentActivity", + id: "activity-started", + kind: "started", + agentThreadId: "child-1", + agentPath: "/root/researcher", + }, + }, + }); + await router.handle({ + method: "item/completed", + params: { + threadId: "root", + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "subAgentActivity", + id: "activity-interrupted", + kind: "interrupted", + agentThreadId: "child-1", + agentPath: "/root/researcher", + }, + }, + }); + + expect(updates).toEqual([ + { + sessionUpdate: "_subagent_update", + subagentSessionId: "child-1", + name: "Researcher", + task: "Delegated task for Researcher", + capabilities: {}, + }, + { + sessionUpdate: "_subagent_update", + subagentSessionId: "child-1", + state: "cancelled", + }, + ]); + }); + + it('never emits `_subagent_update` (or `subagent_spawned`) when native subagents are disabled', async () => { + const {session, updates} = v2Session("root"); + const router = new CodexSubagentEventRouter("root", false, session, () => {}); + + const handled = await router.handle({ + method: "item/started", + params: { + threadId: "root", + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "subAgentActivity", + id: "activity-started", + kind: "started", + agentThreadId: "child-1", + agentPath: "/root/researcher", + }, + }, + }); + + // Same as v1: an unsupported client gets no native representation at all -- the ordinary + // event mapper renders the activity as a ordinary tool call instead (untouched by this router). + expect(handled).toBe(false); + expect(updates).toEqual([]); + }); + + function createAsyncTasks(enabled: boolean) { + const {session, updates} = v2Session("root"); + const list = vi.fn<() => Promise>(); + const appServer = {threadBackgroundTerminalsList: list} as unknown as CodexAppServerClient; + const tasks = new CodexBackgroundTerminalTasks(enabled, "root", appServer, session); + return {tasks, list, updates}; + } + + it('renders async task updates as `_async_task_*` when async tasks are enabled', async () => { + const {tasks, list, updates} = createAsyncTasks(true); + list.mockResolvedValue({data: [{itemId: "command-1", processId: "42", command: "python -m http.server"}], nextCursor: null}); + + await tasks.handleNotification({ + method: "item/started", + params: { + threadId: "root", + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "commandExecution", + id: "command-1", + pluginId: null, + scriptPath: null, + command: "python -m http.server", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }, "root"); + await tasks.sync("root", "root"); + + expect(updates).toEqual([ + { + sessionUpdate: "tool_call_update", + toolCallId: "command-1", + _meta: {jetbrains: {air: {asyncTasks: {backgrounded: true}}}}, + }, + { + sessionUpdate: "_async_task_spawned", + asyncTaskId: "command-1", + name: "python -m http.server", + taskType: "shell", + showInTranscript: false, + canStop: true, + toolCallId: "command-1", + }, + ]); + }); + + it('never emits `_async_task_*` (or `async_task_*`) when async tasks are disabled', async () => { + const {tasks, list, updates} = createAsyncTasks(false); + list.mockResolvedValue({data: [], nextCursor: null}); + + await tasks.handleNotification({ + method: "item/started", + params: { + threadId: "root", + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "commandExecution", + id: "command-1", + pluginId: null, + scriptPath: null, + command: "python -m http.server", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }, "root"); + await tasks.sync("root", "root"); + + expect(updates).toEqual([]); + expect(list).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/terminal-v2.test.ts b/src/__tests__/CodexACPAgent/terminal-v2.test.ts new file mode 100644 index 000000000..6c8a307fc --- /dev/null +++ b/src/__tests__/CodexACPAgent/terminal-v2.test.ts @@ -0,0 +1,180 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import type {ServerNotification} from '../../app-server'; +import type {CommandAction, ThreadItem} from '../../app-server/v2'; +import { + connectSession, + dump, + itemCompleted, + itemStarted, + type PromptSession, + sessionId, + settle, + turnCompleted, + turnId, + turnStarted, + userMessageItem, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +async function startTurn(client: PromptSession): Promise { + const response = client.sendPrompt([{type: "text", text: "Run the tests"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + await response; +} + +async function finishTurn(client: PromptSession) { + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); +} + +/** Tool call and terminal updates, in the order the client received them. */ +function toolAndTerminalUpdates(client: PromptSession): acpV2.SessionUpdate[] { + return client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => ["tool_call_update", "terminal_update", "terminal_output_chunk"].includes(update.sessionUpdate)); +} + +/** Snapshot text with each base64 payload followed by its decoded text, for readability. */ +function dumpDecoded(updates: acpV2.SessionUpdate[]): string { + return dump(updates.map(update => { + if (update.sessionUpdate === "terminal_output_chunk") { + const {data} = update as acpV2.TerminalOutputChunk; + return {...update, decoded: Buffer.from(data, "base64").toString("utf8")}; + } + const {output} = update as acpV2.TerminalUpdate; + if (update.sessionUpdate === "terminal_update" && output) { + return {...update, decodedOutput: Buffer.from(output.data, "base64").toString("utf8")}; + } + return update; + })); +} + +type CommandItem = Extract; + +function commandItem(id: string, overrides: Partial = {}): CommandItem { + return { + type: "commandExecution", + id, + pluginId: null, + scriptPath: null, + command: "/bin/zsh -lc 'npm test'", + cwd: "/workspace", + processId: "proc-1", + source: "agent", + status: "inProgress", + commandActions: [{type: "unknown", command: "npm test"}], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + ...overrides, + }; +} + +function completed(item: CommandItem, aggregatedOutput: string, exitCode: number): CommandItem { + return {...item, status: exitCode === 0 ? "completed" : "failed", aggregatedOutput, exitCode, durationMs: 12}; +} + +function outputDelta(itemId: string, delta: string): ServerNotification { + return {method: "item/commandExecution/outputDelta", params: {threadId: sessionId, turnId, itemId, delta}}; +} + +const readAction: CommandAction = {type: "read", command: "cat src/a.ts", name: "a.ts", path: "/workspace/src/a.ts"}; + +describe('agent-owned terminals over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + async function connect(options: Parameters[1] = {}): Promise { + const client = await connectSession(2, options); + closeClient = () => client.connection.close(); + await startTurn(client); + return client; + } + + it('announces the terminal, streams output as base64 chunks and reports the exit', async () => { + const client = await connect(); + const item = commandItem("item-shell"); + + client.emit(itemStarted(item)); + client.emit(outputDelta("item-shell", "PASS a.test.ts\n")); + client.emit(outputDelta("item-shell", "Tests: 1 passed ✓\n")); + client.emit({ + method: "item/commandExecution/terminalInteraction", + params: {threadId: sessionId, turnId, itemId: "item-shell", processId: "proc-1", stdin: "q"}, + }); + client.emit(itemCompleted(completed(item, "PASS a.test.ts\nTests: 1 passed ✓\n", 0))); + await finishTurn(client); + + await expect(dumpDecoded(toolAndTerminalUpdates(client))) + .toMatchFileSnapshot('data/terminal-v2-streamed.json'); + }); + + it('sends the whole output as the final snapshot when nothing was streamed', async () => { + const client = await connect(); + const item = commandItem("item-shell", {commandActions: []}); + + client.emit(itemStarted(item)); + client.emit(itemCompleted(completed(item, "hello\n", 0))); + await finishTurn(client); + + const updates = toolAndTerminalUpdates(client); + // A generic shell command's first update creates the tool call with its title. + expect(updates.find(update => update.sessionUpdate === "tool_call_update")) + .toMatchObject({toolCallId: "item-shell", title: "npm test", content: [{type: "terminal", terminalId: "item-shell"}]}); + // The terminal shows the same text as the title; the raw input keeps the shell wrapper. + expect(updates[0]).toMatchObject({sessionUpdate: "terminal_update", command: "npm test"}); + await expect(dumpDecoded(updates)).toMatchFileSnapshot('data/terminal-v2-snapshot.json'); + }); + + it('reports a failed command with its exit code', async () => { + const client = await connect(); + const item = commandItem("item-shell"); + + client.emit(itemStarted(item)); + client.emit(outputDelta("item-shell", "npm ERR! Test failed.\n")); + client.emit(itemCompleted(completed(item, "npm ERR! Test failed.\n", 1))); + await finishTurn(client); + + await expect(dumpDecoded(toolAndTerminalUpdates(client))) + .toMatchFileSnapshot('data/terminal-v2-failed.json'); + }); + + it('sends the output of a command without a terminal as content at the end', async () => { + // The private v1 capability does not change the v2 output shape. + const client = await connect({clientCapabilities: {_meta: {terminal_output_delta: true}}}); + const item = commandItem("item-read", {command: "cat src/a.ts", commandActions: [readAction]}); + + client.emit(itemStarted(item)); + client.emit(outputDelta("item-read", "export {};\n")); + client.emit(itemCompleted(completed(item, "export {};\n", 0))); + await finishTurn(client); + + const updates = toolAndTerminalUpdates(client); + expect(updates.map(update => update.sessionUpdate)).toEqual(["tool_call_update", "tool_call_update"]); + await expect(dumpDecoded(updates)).toMatchFileSnapshot('data/terminal-v2-no-terminal.json'); + }); + + it('streams a terminal command the same way when the client sends the private v1 capability', async () => { + const client = await connect({clientCapabilities: {_meta: {terminal_output: true}}}); + const item = commandItem("item-shell"); + + client.emit(itemStarted(item)); + client.emit(outputDelta("item-shell", "ok\n")); + client.emit(itemCompleted(completed(item, "ok\n", 0))); + await finishTurn(client); + + expect(toolAndTerminalUpdates(client).map(update => update.sessionUpdate)).toEqual([ + "terminal_update", "tool_call_update", "terminal_output_chunk", "terminal_update", "tool_call_update", + ]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/test-utils.ts b/src/__tests__/CodexACPAgent/test-utils.ts index aea529c12..c20055c14 100644 --- a/src/__tests__/CodexACPAgent/test-utils.ts +++ b/src/__tests__/CodexACPAgent/test-utils.ts @@ -6,10 +6,13 @@ export interface MockConnections { mockCodexConnection: any; notificationHandlers: Map; getUnhandledNotificationHandler: () => Function | null; + /** The handler `CodexAppServerClient` registered for a `vscode-jsonrpc` `RequestType` (by its `.method`). */ + getRequestHandler: (method: string) => Function | null; } export function createMockConnections(): MockConnections { const notificationHandlers = new Map(); + const requestHandlers = new Map(); let unhandledNotificationHandler: Function | null = null; const mockAcpConnection = { @@ -25,7 +28,9 @@ export function createMockConnections(): MockConnections { onNotification: vi.fn((method: string, handler: Function) => { notificationHandlers.set(method, handler); }), - onRequest: vi.fn(), + onRequest: vi.fn((type: {method: string}, handler: Function) => { + requestHandlers.set(type.method, handler); + }), end: vi.fn(), }; @@ -34,6 +39,7 @@ export function createMockConnections(): MockConnections { mockCodexConnection, notificationHandlers, getUnhandledNotificationHandler: () => unhandledNotificationHandler, + getRequestHandler: (method: string) => requestHandlers.get(method) ?? null, }; } diff --git a/src/__tests__/CodexACPAgent/thread-history.test.ts b/src/__tests__/CodexACPAgent/thread-history.test.ts index 8451d4c88..9535eb32f 100644 --- a/src/__tests__/CodexACPAgent/thread-history.test.ts +++ b/src/__tests__/CodexACPAgent/thread-history.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from "vitest"; -import type {ThreadItem, ThreadItemsListParams, ThreadTurnsListParams, Turn} from "../../app-server/v2"; +import type {ThreadItem, ThreadItemEntry, ThreadItemsListParams, ThreadTurnsListParams, Turn} from "../../app-server/v2"; import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; function messageTurn(id: string): Turn { @@ -59,9 +59,9 @@ function itemStore(turns: Turn[], pageSize = 2) { }; } -async function collect(history: AsyncIterable | null): Promise { +async function collect(history: AsyncIterable> | null): Promise { const ids: string[] = []; - for await (const page of history ?? []) ids.push(...page.map(item => item.id)); + for await (const page of history ?? []) ids.push(...page.map(item => "turnId" in item ? item.item.id : item.id)); return ids; } diff --git a/src/__tests__/CodexACPAgent/tool-calls-and-messages-v2.test.ts b/src/__tests__/CodexACPAgent/tool-calls-and-messages-v2.test.ts new file mode 100644 index 000000000..1ed6dcec4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/tool-calls-and-messages-v2.test.ts @@ -0,0 +1,171 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import type {ServerNotification} from '../../app-server'; +import type {ThreadItem} from '../../app-server/v2'; +import { + connectSession, + dump, + itemCompleted, + itemStarted, + type PromptSession, + sessionId, + settle, + turnCompleted, + turnId, + turnStarted, + userMessageItem, +} from './v2-prompt-harness'; +import {expectConformingV2SessionUpdates, v2SessionUpdateViolation} from './v2-session-update-guard'; + +/** Sends a prompt and lets Codex record it, so the turn is running on the client side. */ +async function startTurn(client: PromptSession): Promise { + const response = client.sendPrompt([{type: "text", text: "Hello"}]); + await vi.waitFor(() => expect(client.turnStartParams).toHaveLength(1)); + const clientUserMessageId = client.turnStartParams[0]!["clientUserMessageId"] as string; + client.emit(turnStarted()); + client.emit(itemCompleted(userMessageItem(clientUserMessageId))); + const {messageId} = await response; + return messageId; +} + +async function finishTurn(client: PromptSession) { + client.emit(turnCompleted()); + await client.promptRunFinished(); + await settle(); +} + +/** The session updates in the transcript with one of the given tags. */ +function updatesTagged(client: PromptSession, ...tags: string[]): acpV2.SessionUpdate[] { + return client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) + .filter(update => tags.includes(update.sessionUpdate)); +} + +function commandItem(id: string, commandActions: Extract["commandActions"], status: "inProgress" | "completed" = "inProgress"): ThreadItem { + return { + type: "commandExecution", + id, + pluginId: null, + scriptPath: null, + command: commandActions[0]?.command ?? "npm test", + cwd: "/workspace", + processId: null, + source: "agent", + status, + commandActions, + aggregatedOutput: status === "completed" ? "export {};\n" : null, + exitCode: status === "completed" ? 0 : null, + durationMs: status === "completed" ? 5 : null, + }; +} + +const readAction = {type: "read", command: "cat src/a.ts", name: "a.ts", path: "/workspace/src/a.ts"} as const; + +describe('tool calls and messages over ACP v2', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('gives every agent and thought chunk a messageId shared by the chunks of one message', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + await startTurn(client); + + const emitDelta = (method: "item/agentMessage/delta" | "item/reasoning/summaryTextDelta", itemId: string, delta: string) => + client.emit({method, params: {threadId: sessionId, turnId, itemId, delta, summaryIndex: 0}} as ServerNotification); + emitDelta("item/reasoning/summaryTextDelta", "item-reasoning", "Thinking "); + emitDelta("item/reasoning/summaryTextDelta", "item-reasoning", "hard"); + emitDelta("item/agentMessage/delta", "item-agent", "Hel"); + emitDelta("item/agentMessage/delta", "item-agent", "lo"); + // A turn error is reported as a one-off agent chunk with no Codex item behind it. + client.emit({ + method: "error", + params: { + threadId: sessionId, + turnId, + willRetry: false, + error: {message: "Something went wrong", codexErrorInfo: null, additionalDetails: null, misalignment: null}, + }, + }); + await finishTurn(client); + + const chunks = updatesTagged(client, "agent_message_chunk", "agent_thought_chunk") as Array; + expect(chunks.map(chunk => chunk.messageId).slice(0, 4)) + .toEqual(["item-reasoning", "item-reasoning", "item-agent", "item-agent"]); + const mintedId = chunks[4]!.messageId; + expect(mintedId).toEqual(expect.any(String)); + expect(mintedId).not.toBe(""); + expect(["item-reasoning", "item-agent"]).not.toContain(mintedId); + await expect(dump(chunks).replaceAll(mintedId, "")) + .toMatchFileSnapshot('data/tool-calls-and-messages-v2-message-ids.json'); + }); + + it('sends a tool call create as a tool_call_update upsert and passes later updates through', async () => { + const client = await connectSession(); + closeClient = () => client.connection.close(); + await startTurn(client); + + client.emit(itemStarted(commandItem("item-read", [readAction]))); + client.emit(itemCompleted(commandItem("item-read", [readAction], "completed"))); + await finishTurn(client); + + const updates = updatesTagged(client, "tool_call", "tool_call_update"); + expect(updates.map(update => update.sessionUpdate)).toEqual(["tool_call_update", "tool_call_update"]); + await expect(dump(updates)).toMatchFileSnapshot('data/tool-calls-and-messages-v2-tool-call-upsert.json'); + }); + + it('reports MCP server startup failures as tool_call_update upserts', async () => { + const client = await connectSession(2, { + mcpServers: [ + {type: "stdio", name: "docs", command: "/usr/bin/docs-mcp"}, + {type: "stdio", name: "slow", command: "/usr/bin/slow-mcp"}, + ], + mcpStartup: {ready: [], failed: [{server: "docs", error: "exit code 1"}], cancelled: ["slow"]}, + }); + closeClient = () => client.connection.close(); + + const startupUpdates = () => [ + ...client.setupUpdates, + ...client.transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []), + ].filter(update => (update as {toolCallId?: string}).toolCallId?.startsWith("mcp_startup.")); + await vi.waitFor(() => expect(startupUpdates()).toHaveLength(2)); + + // Each startup report has a unique id suffix. + await expect(dump(startupUpdates()).replace(/(mcp_startup\.\w+)\.[0-9a-f-]{36}/g, "$1.")) + .toMatchFileSnapshot('data/tool-calls-and-messages-v2-mcp-startup.json'); + }); + + it('flags unknown unprefixed session update tags and v1-only shapes', () => { + const violations = [ + {sessionUpdate: "_vendor/custom", value: 1}, + {sessionUpdate: "subagent_spawned", subagentSessionId: "child"}, + {sessionUpdate: "tool_call", toolCallId: "call-1", title: "Run tests"}, + {sessionUpdate: "agent_message_chunk", content: {type: "text", text: "no id"}}, + {sessionUpdate: "agent_message_chunk", messageId: "", content: {type: "text", text: "empty id"}}, + {sessionUpdate: "agent_message_chunk", messageId: "m-1", content: {type: "text", text: "ok"}}, + {sessionUpdate: "tool_call_update", toolCallId: "call-1", status: "completed"}, + {sessionUpdate: "tool_call_update", toolCallId: "call-1", _meta: {terminal_exit: {exit_code: 0}}}, + {sessionUpdate: "terminal_update", terminalId: "call-1", cwd: "workspace"}, + {sessionUpdate: "terminal_update", terminalId: "call-1", output: {data: "not base64!"}}, + {sessionUpdate: "terminal_output_chunk", terminalId: "call-1", data: "aGk="}, + ].map(update => [update.sessionUpdate, v2SessionUpdateViolation(update as acpV2.SessionUpdate)]); + + expect(violations).toEqual([ + ["_vendor/custom", null], + ["subagent_spawned", "unknown unprefixed sessionUpdate 'subagent_spawned'"], + ["tool_call", "unknown unprefixed sessionUpdate 'tool_call'"], + ["agent_message_chunk", expect.stringContaining("'agent_message_chunk' does not match its v2 schema")], + ["agent_message_chunk", "'agent_message_chunk' has an empty messageId"], + ["agent_message_chunk", null], + ["tool_call_update", null], + ["tool_call_update", "'tool_call_update' carries private terminal _meta keys: terminal_exit"], + ["terminal_update", "'terminal_update' has a relative cwd: workspace"], + ["terminal_update", "'terminal_update' output is not base64: not base64!"], + ["terminal_output_chunk", null], + ]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/v2-prompt-harness.ts b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts new file mode 100644 index 000000000..1e7f06b8e --- /dev/null +++ b/src/__tests__/CodexACPAgent/v2-prompt-harness.ts @@ -0,0 +1,433 @@ +import {expect, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import type {ServerNotification} from '../../app-server'; +import type {Thread, ThreadItem, Turn, TurnStatus} from '../../app-server/v2'; +import type {McpStartupResult} from '../../mcp/McpStartupTracker'; +import {createTestModel} from '../acp-test-utils'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate} from './v2-session-update-guard'; + +export const sessionId = "thread-1"; +export const turnId = "turn-1"; +export const cwd = "/workspace"; +export const titleThreadId = "title-thread"; + +export function createThread(): Thread { + return { + id: sessionId, + sessionId, + parentThreadId: null, + threadSource: null, + originator: null, + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + model: null, + reasoningEffort: null, + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd, + cliVersion: "0.0.0", + section: null, + sectionEnteredAt: null, + projectId: null, + historyMode: "legacy", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; +} + +export function createTurn(status: TurnStatus, id = turnId): Turn { + return { + id, + items: [], + itemsView: "notLoaded", + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; +} + +/** Canned Codex app-server responses, keyed by method. */ +export function codexResponse(method: string): unknown { + switch (method) { + case "thread/start": + case "thread/resume": + return { + thread: createThread(), + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + turnsBackwardsCursor: null, + }; + case "model/list": + return {data: [createTestModel({id: "gpt-5"})], nextCursor: null}; + case "skills/list": + return {data: []}; + case "config/read": + return {config: {}, origins: {}, layers: []}; + case "thread/goal/get": + return {goal: null}; + default: + return {}; + } +} + +/** The client-visible transcript of a prompt, in wire order. */ +export type TranscriptEntry = + | {codexRequest: string, params: unknown} + | {codexResponse: string} + | {codexNotification: string} + | {sessionUpdate: acpV2.SessionUpdate} + | {promptResponse: unknown} + | {promptError: unknown} + | {permissionRequest: acpV2.RequestPermissionRequest} + | {elicitationRequest: acpV2.CreateElicitationRequest} + | {elicitationComplete: acpV2.CompleteElicitationNotification}; + +export type PromptSession = { + connection: {close(): void}; + transcript: TranscriptEntry[]; + /** Updates published while the session was being set up, before `transcript` starts recording. */ + setupUpdates: acpV2.SessionUpdate[]; + turnStartParams: Array>; + setTurnStart(handler: (params: Record) => Promise): void; + /** Overrides the canned Codex response for a request method other than `turn/start`. */ + setCodexResponse(method: string, handler: (params: unknown) => Promise): void; + appServer: CodexAppServerClient; + emit(notification: ServerNotification): void; + /** `signal`, if given, sends `$/cancel_request` for this specific `session/prompt` on abort. */ + sendPrompt(prompt: acpV2.ContentBlock[], signal?: AbortSignal): Promise; + /** Sends any other agent-bound request (e.g. `session/set_config_option`) on the same connection. */ + request(method: string, params: unknown, signal?: AbortSignal): Promise; + /** Sends `session/cancel` (a notification, not a request) for this session. */ + cancel(): Promise; + /** Resolves when the n-th internal prompt run (including its background turn) has finished. */ + promptRunFinished(index?: number): Promise; + /** + * Simulates the app-server calling back for a command/file-change/permissions approval, by + * invoking the handler `CodexAppServerClient` registered for it (e.g. + * `CommandExecutionApprovalRequest.method`). + */ + triggerApproval(method: string, params: unknown): Promise; + /** The `{threadId, turnId}` params of every `turn/interrupt` sent to Codex so far, in order. */ + turnInterruptCalls(): Array<{threadId: string, turnId: string}>; + /** The agent instance behind the connection, for spying on private methods (e.g. `restartCodexClient`). */ + agent: CodexAcpServer; +}; + +/** Connects a client (v2 by default) to the agent through the router, over a mocked Codex app-server, and opens a session. */ +export async function connectSession(protocolVersion: 1 | 2 = 2, options: { + mcpServers?: acpV2.McpServer[], + mcpStartup?: McpStartupResult, + /** Sent on `initialize` (v2 only). */ + clientCapabilities?: acpV2.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. */ + onRequestPermission?: (request: acpV2.RequestPermissionRequest, signal: AbortSignal) => acpV2.RequestPermissionResponse | Promise, + /** Answers `elicitation/create` (v2 only). 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 + * once `connectSession` has returned). Needed for responses a fire-and-forget background task + * (e.g. MCP OAuth re-auth) sends before the caller gets a chance to call `setCodexResponse`. + */ + codexResponses?: Record Promise>, +} = {}): Promise { + const mocks = createMockConnections(); + const transcript: TranscriptEntry[] = []; + const turnStartParams: Array> = []; + const codexResponseOverrides = new Map Promise>( + Object.entries(options.codexResponses ?? {}), + ); + let turnStart: (params: Record) => Promise = async () => ({ + turn: createTurn("inProgress", `turn-${turnStartParams.length}`), + }); + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string, params?: any) => { + // The session title is generated on a separate ephemeral thread; keep it out of the way. + if (method === "thread/start" && params?.ephemeral) { + return {thread: {...createThread(), id: titleThreadId, ephemeral: true}}; + } + if (method === "turn/start" && params?.threadId === titleThreadId) { + return {turn: createTurn("inProgress", "title-turn")}; + } + if (method !== "turn/start") { + const override = codexResponseOverrides.get(method); + return override ? await override(params) : codexResponse(method); + } + turnStartParams.push(params as Record); + transcript.push({codexRequest: method, params}); + const response = await turnStart(params as Record); + transcript.push({codexResponse: method}); + return response; + }); + const appServer = new CodexAppServerClient(mocks.mockCodexConnection as any); + const codexAcpClient = new CodexAcpClient(appServer); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockResolvedValue(options.mcpStartup ?? {ready: [], failed: [], cancelled: []}); + let agent: CodexAcpServer | null = null; + const router = createAcpAgentRouter((connection) => { + agent = new CodexAcpServer(connection, codexAcpClient, undefined, options.exitCode); + return agent; + }); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + let recordUpdates = false; + const setupUpdates: acpV2.SessionUpdate[] = []; + const onUpdate = (update: acpV2.SessionUpdate | acp.SessionUpdate) => { + if (protocolVersion === 2) { + checkV2SessionUpdate(update as acpV2.SessionUpdate); + } + if (recordUpdates) { + transcript.push({sessionUpdate: update as acpV2.SessionUpdate}); + } else { + setupUpdates.push(update as acpV2.SessionUpdate); + } + }; + const clientStream = acp.ndJsonStream(clientToAgent.writable, agentToClient.readable); + let connection: {close(): void}; + let request: (method: string, params: unknown, signal?: AbortSignal) => Promise; + let notifyCancel: () => Promise; + if (protocolVersion === 2) { + const v2Connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => onUpdate(ctx.params.update)) + .onRequest(acpV2.methods.client.session.requestPermission, async (ctx) => { + transcript.push({permissionRequest: ctx.params}); + if (!options.onRequestPermission) { + throw new Error("Received a permission request with no onRequestPermission handler configured"); + } + return await options.onRequestPermission(ctx.params, ctx.signal); + }) + .onRequest(acpV2.methods.client.elicitation.create, async (ctx) => { + transcript.push({elicitationRequest: ctx.params}); + if (!options.onElicitation) { + throw new Error("Received an elicitation request with no onElicitation handler configured"); + } + return await options.onElicitation(ctx.params, ctx.signal); + }) + .onNotification(acpV2.methods.client.elicitation.complete, (ctx) => { + transcript.push({elicitationComplete: ctx.params}); + }) + .connect(clientStream); + await v2Connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + ...(options.clientCapabilities ? {capabilities: options.clientCapabilities} : {}), + }); + await v2Connection.agent.request(acpV2.methods.agent.session.new, {cwd, ...(options.mcpServers ? {mcpServers: options.mcpServers} : {})}); + connection = v2Connection; + request = (method, params, signal) => v2Connection.agent.request( + method as typeof acpV2.methods.agent.session.prompt, + params as acpV2.PromptRequest, + signal ? {cancellationSignal: signal} : undefined, + ); + notifyCancel = () => v2Connection.agent.notify(acpV2.methods.agent.session.cancel, {sessionId}); + } else { + const v1Connection = acp.client({name: "test-client"}) + .onNotification(acp.methods.client.session.update, (ctx) => onUpdate(ctx.params.update)) + .connect(clientStream); + await v1Connection.agent.request(acp.methods.agent.initialize, {protocolVersion: acp.PROTOCOL_VERSION}); + await v1Connection.agent.request(acp.methods.agent.session.new, {cwd, mcpServers: []}); + connection = v1Connection; + request = (method, params, signal) => v1Connection.agent.request( + method as typeof acp.methods.agent.session.prompt, + params as acp.PromptRequest, + signal ? {cancellationSignal: signal} : undefined, + ); + notifyCancel = () => v1Connection.agent.notify(acp.methods.agent.session.cancel, {sessionId}); + } + const promptSpy = vi.spyOn(agent!, "prompt"); + // Session setup publishes updates of its own; wait for them before recording the prompt. + await vi.waitFor(() => expect(setupUpdates.map(update => update.sessionUpdate)).toContain("available_commands_update")); + recordUpdates = true; + + const emit = (notification: ServerNotification) => { + transcript.push({codexNotification: notification.method}); + mocks.getUnhandledNotificationHandler()!(notification); + }; + + const sendPrompt = (prompt: acpV2.ContentBlock[], signal?: AbortSignal) => { + const response = request("session/prompt", {sessionId, prompt}, signal).then( + (result) => { + transcript.push({promptResponse: result}); + return result; + }, + (error) => { + transcript.push({promptError: {code: error.code, message: error.message, data: error.data}}); + throw error; + }, + ); + response.catch(() => {}); + return response; + }; + + return { + connection, + transcript, + setupUpdates, + turnStartParams, + setTurnStart: (handler: typeof turnStart) => { + turnStart = handler; + }, + setCodexResponse: (method, handler) => { + codexResponseOverrides.set(method, handler); + }, + appServer, + emit, + sendPrompt, + request, + cancel: () => notifyCancel(), + promptRunFinished: async (index = 0) => { + await vi.waitFor(() => expect(promptSpy.mock.results.length).toBeGreaterThan(index)); + await promptSpy.mock.results[index]!.value.catch(() => {}); + }, + triggerApproval: async (method, params) => { + const handler = mocks.getRequestHandler(method); + if (!handler) { + throw new Error(`No handler registered for '${method}'`); + } + return await handler(params); + }, + turnInterruptCalls: () => mocks.mockCodexConnection.sendRequest.mock.calls + .filter(([method]: [string]) => method === "turn/interrupt") + .map(([, params]: [string, {threadId: string, turnId: string}]) => params), + agent: agent!, + }; +} + +/** + * Builds a standalone mocked `CodexAcpClient` (with its own mocked Codex connection), suitable as + * the replacement `restartCodexClient()` resolves to in a provider-restart test. Spied the same + * way `connectSession`'s own client is, plus an `emit` to fire notifications as if they came from + * this replacement's (post-restart) app-server process. + */ +export function createReplacementCodexAcpClient(): { + codexAcpClient: CodexAcpClient; + appServer: CodexAppServerClient; + emit(notification: ServerNotification): void; + turnStartParams: Array>; + /** Overrides the canned `turn/start` response, e.g. to run a prompt through this replacement. */ + setTurnStart(handler: (params: Record) => Promise): void; +} { + const mocks = createMockConnections(); + const turnStartParams: Array> = []; + let turnStart: (params: Record) => Promise = async () => ({ + turn: createTurn("inProgress", `replacement-turn-${turnStartParams.length}`), + }); + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string, params?: any) => { + if (method !== "turn/start") { + return codexResponse(method); + } + turnStartParams.push(params as Record); + return await turnStart(params as Record); + }); + const appServer = new CodexAppServerClient(mocks.mockCodexConnection as any); + const codexAcpClient = new CodexAcpClient(appServer); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + return { + codexAcpClient, + appServer, + emit: (notification) => mocks.getUnhandledNotificationHandler()!(notification), + turnStartParams, + setTurnStart: (handler) => { + turnStart = handler; + }, + }; +} + +export function userMessageItem(clientId: string | null, text = "Hello"): ThreadItem { + return {type: "userMessage", id: "item-user", clientId, content: [{type: "text", text, text_elements: []}]}; +} + +/** A long-running command (no single known action, so it uses a terminal) */ +export function commandExecutionItem(id: string, status: "inProgress" | "completed" | "failed" = "inProgress"): ThreadItem { + return { + type: "commandExecution", + id, + pluginId: null, + scriptPath: null, + command: "sleep 12", + cwd, + processId: null, + source: "agent", + status, + commandActions: [], + aggregatedOutput: status === "inProgress" ? null : "", + exitCode: status === "inProgress" ? null : 0, + durationMs: null, + }; +} + +export function itemStarted(item: ThreadItem, id = turnId): ServerNotification { + return {method: "item/started", params: {threadId: sessionId, turnId: id, item, startedAtMs: 0}}; +} + +export function itemCompleted(item: ThreadItem, id = turnId): ServerNotification { + return {method: "item/completed", params: {threadId: sessionId, turnId: id, item, completedAtMs: 0}}; +} + +export function agentMessageDelta(delta: string, id = turnId, itemId = "item-1"): ServerNotification { + return {method: "item/agentMessage/delta", params: {threadId: sessionId, turnId: id, itemId, delta}}; +} + +export function turnStarted(id = turnId): ServerNotification { + return {method: "turn/started", params: {threadId: sessionId, turn: createTurn("inProgress", id)}}; +} + +export function turnCompleted(id = turnId): ServerNotification { + return {method: "turn/completed", params: {threadId: sessionId, turn: createTurn("completed", id)}}; +} + +/** Lets queued notifications and wire messages settle. */ +export async function settle() { + await new Promise(resolve => setTimeout(resolve, 20)); +} + +/** The `state_update`s in the transcript, in wire order. */ +export function stateUpdates(transcript: TranscriptEntry[]): Array<{state: string, stopReason?: unknown}> { + return transcript.flatMap(entry => "sessionUpdate" in entry && entry.sessionUpdate.sessionUpdate === "state_update" + ? [{...entry.sessionUpdate} as {state: string, stopReason?: unknown}] + : []) + .map(({state, stopReason}) => stopReason === undefined ? {state} : {state, stopReason}); +} + +/** Index of the first transcript entry matching the predicate. */ +export function indexOf(transcript: TranscriptEntry[], predicate: (entry: TranscriptEntry) => boolean): number { + return transcript.findIndex(predicate); +} + +export const isState = (state: string) => (entry: TranscriptEntry) => + "sessionUpdate" in entry && entry.sessionUpdate.sessionUpdate === "state_update" + && (entry.sessionUpdate as {state: string}).state === state; + +export function turnFinished(status: TurnStatus, id = turnId): ServerNotification { + return {method: "turn/completed", params: {threadId: sessionId, turn: createTurn(status, id)}}; +} + +export function dump(value: unknown, messageId?: string): string { + const json = `${JSON.stringify(value, null, 2)}\n`; + return messageId ? json.replaceAll(messageId, "") : json; +} diff --git a/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts new file mode 100644 index 000000000..731c66a56 --- /dev/null +++ b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts @@ -0,0 +1,262 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import {AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_ASYNC_TASKS_KEY} from '../../AirExtension'; +import type {Thread} from '../../app-server/v2'; +import {createTestModel} from '../acp-test-utils'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +const rootId = "root-1"; +const childId = "child-1"; +const cwd = "/workspace"; + +function createThread(id: string, overrides?: Partial): Thread { + return { + id, + sessionId: id, + parentThreadId: null, + threadSource: null, + originator: null, + forkedFromId: null, + preview: "Earlier session", + ephemeral: false, + modelProvider: "openai", + model: null, + reasoningEffort: null, + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd, + cliVersion: "0.0.0", + section: null, + sectionEnteredAt: null, + projectId: null, + historyMode: "legacy", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + ...overrides, + }; +} + +/** + * Root thread with a completed subagent delegation: a `subAgentActivity` "started" followed by + * "completed" for `childId`, so `streamNativeThreadHistory` announces the child, replays its + * history, then marks it terminal -- exactly the shape that used to throw -32603 through the + * fail-loud v2 renderer. + */ +const rootTurns: Thread["turns"] = [ + { + id: "root-turn-1", + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + {type: "subAgentActivity", id: "activity-started", kind: "started", agentThreadId: childId, agentPath: `/${rootId}/researcher`}, + {type: "subAgentActivity", id: "activity-completed", kind: "completed", agentThreadId: childId, agentPath: `/${rootId}/researcher`}, + ], + }, +]; + +/** Child thread history: an agent reply plus a still-running background command. */ +const childTurns: Thread["turns"] = [ + { + id: "child-turn-1", + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + { + type: "commandExecution", + id: "child-command-1", + pluginId: null, + scriptPath: null, + command: "npm test", + cwd, + processId: "42", + source: "unifiedExecStartup", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + { + type: "agentMessage", + id: "child-reply", + text: "Investigating the flaky test.", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + ], + }, +]; + +/** Canned Codex app-server responses for a two-thread (root + subagent) fixture, keyed by method and threadId. */ +function codexResponse(method: string, params: unknown): unknown { + const threadId = (params as {threadId?: string} | undefined)?.threadId; + switch (method) { + case "thread/resume": + return { + thread: createThread(threadId ?? rootId), + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + turnsBackwardsCursor: null, + }; + case "thread/read": + if (threadId === childId) return {thread: createThread(childId, {turns: childTurns})}; + return {thread: createThread(rootId, {turns: rootTurns})}; + case "thread/backgroundTerminals/list": + if (threadId === childId) { + return {data: [{itemId: "child-command-1", processId: "42", command: "npm test"}], nextCursor: null}; + } + return {data: [], nextCursor: null}; + case "model/list": + return {data: [createTestModel({id: "gpt-5"})], nextCursor: null}; + case "skills/list": + return {data: []}; + case "config/read": + return {config: {}, origins: {}, layers: []}; + case "thread/list": + return {data: [createThread(rootId)], nextCursor: null}; + case "thread/goal/get": + return {goal: null}; + default: + return {}; + } +} + +/** Connects a v2 client to the agent through the router, over a mocked (threadId-aware) Codex app-server. */ +async function connectV2Client(capabilities: acpV2.ClientCapabilities) { + const mocks = createMockConnections(); + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string, params: unknown) => ( + codexResponse(method, params) + )); + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockResolvedValue({ready: [], failed: [], cancelled: []}); + const router = createAcpAgentRouter((connection) => new CodexAcpServer(connection, codexAcpClient)); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + const updates: acpV2.UpdateSessionNotification[] = []; + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => { + checkV2SessionUpdate(ctx.params.update); + updates.push(ctx.params); + }) + .connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable)); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + capabilities, + }); + return {connection, updates}; +} + +describe('ACP v2 resume replay: native subagent history (nativeSubagentSessions client)', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('replays a thread with subAgentActivity to completion instead of failing with -32603', async () => { + const {connection, updates} = await connectV2Client({ + _meta: {jetbrains: {air: {version: 1, capabilities: [AIR_NATIVE_SUBAGENT_SESSIONS_KEY]}}}, + }); + closeClient = () => connection.close(); + + // Before this slice, the child's `subagent_spawned`/`subagent_state_update` updates hit + // the fail-loud renderer and this request rejected with -32603 partway through replay. + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId: rootId, + cwd, + replayFrom: {type: "start"}, + }); + + const subagentUpdates = updates.map(({update}) => update).filter((update) => update.sessionUpdate === "_subagent_update"); + expect(subagentUpdates).toEqual([ + {sessionUpdate: "_subagent_update", subagentSessionId: childId, name: "Researcher", task: "Delegated task for Researcher", capabilities: {}}, + {sessionUpdate: "_subagent_update", subagentSessionId: childId, state: "completed"}, + ]); + + // Async tasks aren't gated on in this capability set, so nothing async-task-shaped renders. + expect(updates.some(({update}) => update.sessionUpdate.startsWith("_async_task_"))).toBe(false); + }); + + it('also emits the child subagent history (agent reply) alongside the lifecycle updates', async () => { + const {connection, updates} = await connectV2Client({ + _meta: {jetbrains: {air: {version: 1, capabilities: [AIR_NATIVE_SUBAGENT_SESSIONS_KEY]}}}, + }); + closeClient = () => connection.close(); + + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId: rootId, + cwd, + replayFrom: {type: "start"}, + }); + + const agentMessages = updates + .map(({update}) => update) + .filter((update) => update.sessionUpdate === "agent_message_chunk") + .map((update) => (update as {content: {type: string; text?: string}}).content) + .map((content) => (content.type === "text" ? content.text : null)); + expect(agentMessages).toContain("Investigating the flaky test."); + }); + + it('also emits the child subagent async task when the client also supports asyncTasks (report: not silently lost)', async () => { + const {connection, updates} = await connectV2Client({ + _meta: {jetbrains: {air: { + version: 1, + capabilities: [AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_ASYNC_TASKS_KEY], + }}}, + }); + closeClient = () => connection.close(); + + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId: rootId, + cwd, + replayFrom: {type: "start"}, + }); + + const asyncTaskUpdates = updates.map(({update}) => update).filter((update) => update.sessionUpdate === "_async_task_spawned"); + expect(asyncTaskUpdates).toEqual([ + { + sessionUpdate: "_async_task_spawned", + asyncTaskId: "child-1:child-command-1", + name: "npm test", + taskType: "shell", + showInTranscript: false, + canStop: true, + toolCallId: "child-command-1", + }, + ]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/v2-resume-replay.test.ts b/src/__tests__/CodexACPAgent/v2-resume-replay.test.ts new file mode 100644 index 000000000..c922aee3c --- /dev/null +++ b/src/__tests__/CodexACPAgent/v2-resume-replay.test.ts @@ -0,0 +1,442 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as acp from '@agentclientprotocol/sdk'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {createAcpAgentRouter} from '../../AcpAgentRouter'; +import {CodexAcpServer} from '../../CodexAcpServer'; +import {CodexAcpClient} from '../../CodexAcpClient'; +import {CodexAppServerClient} from '../../CodexAppServerClient'; +import {AIR_NATIVE_SUBAGENT_SESSIONS_KEY} from '../../AirExtension'; +import type {Thread} from '../../app-server/v2'; +import {createCodexMockTestFixture, createTestModel} from '../acp-test-utils'; +import {createMockConnections} from './test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +const sessionId = "thread-1"; +const cwd = "/workspace"; + +// UUIDv7-shaped turn ids (version nibble '7'), constructed so a plain string compare recovers +// chronological order except where a turn is deliberately minted "later" than its neighbor. +const P_ID = "01990000-0000-7000-8000-000000000001"; // review turn +const T_ID = "01990000-0000-7000-8000-000000000002"; // reviewer-prompt turn, minted after P_ID +const T2_ID = "01990000-0000-7000-8000-000000000003"; // an ordinary turn +const P2_ID = "01990000-0000-7000-8000-000000000004"; // a later review turn, T2_ID predates it normally + +function createThread(overrides?: Partial): Thread { + return { + id: sessionId, + sessionId, + parentThreadId: null, + threadSource: null, + originator: null, + forkedFromId: null, + preview: "Earlier session", + ephemeral: false, + modelProvider: "openai", + model: null, + reasoningEffort: null, + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd, + cliVersion: "0.0.0", + section: null, + sectionEnteredAt: null, + projectId: null, + historyMode: "legacy", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + ...overrides, + }; +} + +/** + * A thread shaped like real 0.156.1 `/review` history: a reviewer-prompt turn T immediately before its review turn P, with T + * minted after P so its turn id sorts higher; plus an ordinary turn T2 that also happens to sit + * right before a later review turn P2, but in chronological order (T2's id is lower), which must + * NOT be hidden. + */ +const reviewReplayTurns: Thread["turns"] = [ + { + id: T_ID, + itemsView: "full", + status: "interrupted", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + { + type: "userMessage", + id: "reviewer-prompt", + clientId: null, + content: [{type: "text", text: "Please review my changes", text_elements: []}], + }, + {type: "reasoning", id: "reviewer-reasoning", summary: ["Reviewing the diff"], content: []}, + ], + }, + { + id: P_ID, + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + {type: "enteredReviewMode", id: "review-entered", review: "Review the diff"}, + {type: "exitedReviewMode", id: "review-exited", review: "Looks good"}, + { + type: "agentMessage", + id: "review-summary", + text: "Review complete.", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + ], + }, + { + id: T2_ID, + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + { + type: "userMessage", + id: "ordinary-prompt", + clientId: null, + content: [{type: "text", text: "Reply with just OK.", text_elements: []}], + }, + { + type: "agentMessage", + id: "ordinary-reply", + text: "OK", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + ], + }, + { + id: P2_ID, + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + {type: "enteredReviewMode", id: "review2-entered", review: "Second review"}, + {type: "exitedReviewMode", id: "review2-exited", review: "All good"}, + { + type: "agentMessage", + id: "review2-summary", + text: "Second review complete.", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + ], + }, +]; + +/** + * Canned Codex app-server responses, keyed by method. A paginated history lists one item per + * `thread/items/list` page, so every turn boundary is also a page boundary. + */ +function codexResponse( + method: string, + params: {cursor?: string | null; sortDirection?: string} | undefined, + replayTurns: Thread["turns"] = [], + paginated = false, +): unknown { + const entries = replayTurns.flatMap(turn => turn.items.map(item => ({ + turnId: turn.id, + item, + startedAtMs: null, + completedAtMs: null, + }))); + switch (method) { + case "thread/items/list": { + if (params?.sortDirection === "desc") { + return {data: entries.slice(-1), nextCursor: null, backwardsCursor: null}; + } + const index = Number(params?.cursor ?? 0); + return { + data: entries.slice(index, index + 1), + nextCursor: index + 1 < entries.length ? String(index + 1) : null, + backwardsCursor: null, + }; + } + case "thread/start": + case "thread/resume": + return { + thread: createThread(paginated ? {historyMode: "paginated"} : {}), + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + itemsBackwardsCursor: paginated ? "last" : null, + }; + case "thread/read": + return {thread: createThread({turns: replayTurns})}; + case "model/list": + return {data: [createTestModel({id: "gpt-5"})], nextCursor: null}; + case "skills/list": + return {data: []}; + case "config/read": + return {config: {}, origins: {}, layers: []}; + case "thread/list": + return {data: [createThread()], nextCursor: null}; + case "thread/goal/get": + return {goal: null}; + default: + return {}; + } +} + +/** Connects a v2 client to the agent through the router, over a mocked Codex app-server. */ +async function connectV2Client(options?: { + replayTurns?: Thread["turns"]; + capabilities?: acpV2.ClientCapabilities; + paginated?: boolean; +}) { + const mocks = createMockConnections(); + mocks.mockCodexConnection.sendRequest.mockImplementation(async (method: string, params?: any) => ( + codexResponse(method, params, options?.replayTurns, options?.paginated) + )); + const codexAcpClient = new CodexAcpClient(new CodexAppServerClient(mocks.mockCodexConnection as any)); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(codexAcpClient, "getAgentConfiguredModelProvider").mockResolvedValue("openai"); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockResolvedValue({ready: [], failed: [], cancelled: []}); + const router = createAcpAgentRouter((connection) => new CodexAcpServer(connection, codexAcpClient)); + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + router.connect(acp.ndJsonStream(agentToClient.writable, clientToAgent.readable)); + + const updates: acpV2.UpdateSessionNotification[] = []; + const connection = acpV2.client({name: "test-client"}) + .onNotification(acpV2.methods.client.session.update, (ctx) => { + checkV2SessionUpdate(ctx.params.update); + updates.push(ctx.params); + }) + .connect(acp.ndJsonStream(clientToAgent.writable, agentToClient.readable)); + await connection.agent.request(acpV2.methods.agent.initialize, { + protocolVersion: 2, + info: {name: "test-client", version: "1.0.0"}, + ...(options?.capabilities ? {capabilities: options.capabilities} : {}), + }); + const codexMethods = () => mocks.mockCodexConnection.sendRequest.mock.calls.map((call: unknown[]) => call[0] as string); + return {connection, updates, codexMethods}; +} + +function dump(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** The whole-message kind a replayed chunk restarts. */ +const wholeMessageKindByChunk: Record = { + user_message_chunk: "user_message", + agent_message_chunk: "agent_message", + agent_thought_chunk: "agent_thought", +}; + +/** + * Every replayed chunk with an id must be preceded (anywhere earlier in + * the same session's update stream) by a whole-message update of the matching kind and id with + * `content: []`, sent exactly once per id. + */ +function assertPrimerBeforeEveryChunk(updates: acpV2.UpdateSessionNotification[]): void { + const primed = new Set(); + for (let i = 0; i < updates.length; i++) { + const update = updates[i]!.update; + const wholeKind = wholeMessageKindByChunk[update.sessionUpdate]; + const messageId = (update as {messageId?: string | null}).messageId; + if (!wholeKind || !messageId) continue; + const key = `${wholeKind}:${messageId}`; + if (primed.has(key)) continue; + const hasPrimer = updates.slice(0, i).some((prior) => ( + prior.update.sessionUpdate === wholeKind + && (prior.update as {messageId?: string}).messageId === messageId + && Array.isArray((prior.update as {content?: unknown[]}).content) + && (prior.update as {content: unknown[]}).content.length === 0 + )); + expect(hasPrimer, `missing '${wholeKind}' primer before the first '${update.sessionUpdate}' for id ${messageId}`) + .toBe(true); + primed.add(key); + } +} + +/** `(sessionUpdate, messageId)` pairs for updates that carry a replay message id. */ +function messageIdSequence(updates: acpV2.UpdateSessionNotification[]): Array<[string, string | null | undefined]> { + return updates + .filter(({update}) => "messageId" in update) + .map(({update}) => [update.sessionUpdate, (update as {messageId?: string | null}).messageId]); +} + +describe('ACP v2 resume replay: review-mode ids, reviewer-prompt hiding, primers', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + it('gives review-mode chunks a stable messageId, hides only the minted-after reviewer prompt, and primes every replayed chunk', async () => { + const {connection, updates} = await connectV2Client({replayTurns: reviewReplayTurns}); + closeClient = () => connection.close(); + + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: {type: "start"}, + }); + + const messages = updates.filter(({update}) => ( + update.sessionUpdate.endsWith("_message_chunk") + || update.sessionUpdate === "agent_message" + || update.sessionUpdate.endsWith("_thought_chunk") + || update.sessionUpdate === "agent_thought" + )); + await expect(dump(messages)).toMatchFileSnapshot('data/v2-resume-replay-review-and-hide.json'); + assertPrimerBeforeEveryChunk(updates); + }); + + it('hides the reviewer prompt the same way when the history arrives one item per page', async () => { + const replay = async (paginated: boolean) => { + const {connection, updates, codexMethods} = await connectV2Client({replayTurns: reviewReplayTurns, paginated}); + try { + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: {type: "start"}, + }); + expect(codexMethods().includes("thread/items/list")).toBe(paginated); + return messageIdSequence(updates); + } finally { + connection.close(); + } + }; + + const paged = await replay(true); + expect(paged).not.toContainEqual(["user_message_chunk", "reviewer-prompt"]); + expect(paged).toContainEqual(["user_message_chunk", "ordinary-prompt"]); + expect(paged).toEqual(await replay(false)); + }); + + it('leaves the reviewer prompt visible on v1 (regression pin: hiding is v2 only)', async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + codexAcpClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); + codexAcpClient.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); + codexAcpClient.listSkills = vi.fn().mockResolvedValue({data: []}); + const model = createTestModel(); + appServer.listModels = vi.fn().mockResolvedValue({data: [model], nextCursor: null}); + appServer.threadResume = vi.fn().mockResolvedValue({ + thread: createThread(), + model: model.id, + modelProvider: "openai", + cwd, + approvalPolicy: "never", + sandbox: {type: "dangerFullAccess"}, + reasoningEffort: model.defaultReasoningEffort, + }); + appServer.threadReadWithHistory = vi.fn().mockResolvedValue({ + thread: createThread({turns: reviewReplayTurns}), + }); + + await agent.initialize({protocolVersion: 1}); + await agent.loadSession({sessionId, cwd, mcpServers: []}); + + const userChunkTexts = fixture.getAcpConnectionEvents([]) + .filter((event) => event.method === "sessionUpdate") + .map((event) => event.args[0].update) + .filter((update: {sessionUpdate: string}) => update.sessionUpdate === "user_message_chunk") + .map((update: {content: {type: string; text?: string}}) => ( + update.content.type === "text" ? update.content.text : null + )); + + // v1 is unchanged by the hiding rule: both userMessages, including the reviewer prompt, replay. + expect(userChunkTexts).toEqual(["Please review my changes", "Reply with just OK."]); + }); +}); + +describe('ACP v2 resume replay: native path primers and determinism', () => { + let closeClient: (() => void) | null = null; + + afterEach(() => { + closeClient?.(); + closeClient = null; + vi.clearAllMocks(); + expectConformingV2SessionUpdates(); + }); + + const nativeCapabilities: acpV2.ClientCapabilities = { + _meta: {jetbrains: {air: {version: 1, capabilities: [AIR_NATIVE_SUBAGENT_SESSIONS_KEY]}}}, + }; + + it('primes chunked messages in the native (subagent-aware) replay path too', async () => { + const {connection, updates} = await connectV2Client({ + replayTurns: reviewReplayTurns, + capabilities: nativeCapabilities, + }); + closeClient = () => connection.close(); + + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: {type: "start"}, + }); + + assertPrimerBeforeEveryChunk(updates); + // The reviewer-prompt hiding rule also applies to the native path. + const userChunkIds = updates + .filter(({update}) => update.sessionUpdate === "user_message_chunk") + .map(({update}) => (update as {messageId?: string | null}).messageId); + expect(userChunkIds).toEqual(["ordinary-prompt"]); + }); + + it('replays the same thread twice with identical message ids (native path)', async () => { + // Every id below comes from a stable source (`item.id`/`clientId`). + const {connection: firstConnection, updates: firstUpdates} = await connectV2Client({ + replayTurns: reviewReplayTurns, + capabilities: nativeCapabilities, + }); + await firstConnection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: {type: "start"}, + }); + firstConnection.close(); + + const {connection: secondConnection, updates: secondUpdates} = await connectV2Client({ + replayTurns: reviewReplayTurns, + capabilities: nativeCapabilities, + }); + closeClient = () => secondConnection.close(); + await secondConnection.agent.request(acpV2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: {type: "start"}, + }); + + expect(messageIdSequence(secondUpdates)).toEqual(messageIdSequence(firstUpdates)); + }); +}); diff --git a/src/__tests__/CodexACPAgent/v2-session-update-guard.ts b/src/__tests__/CodexACPAgent/v2-session-update-guard.ts new file mode 100644 index 000000000..2d5274579 --- /dev/null +++ b/src/__tests__/CodexACPAgent/v2-session-update-guard.ts @@ -0,0 +1,94 @@ +import {expect} from 'vitest'; +import * as acpV2 from '@agentclientprotocol/sdk/experimental/v2'; +import {DIFF_GIT_PATCH_META_KEY} from '../../AcpV2SessionUpdate'; + +type Guard = (update: acpV2.SessionUpdate) => boolean; + +const knownVariantGuards: Guard[] = Object.entries(acpV2.SessionUpdate) + .filter(([name]) => name !== "isCustom") + .map(([, guard]) => guard as Guard); + +/** Private v1 tool call `_meta` keys for command output; v2 sends standard terminal updates instead. */ +const privateTerminalMetaKeys = ["terminal_info", "terminal_output", "terminal_output_delta", "terminal_exit"]; + +function isBase64(data: unknown): boolean { + return typeof data === "string" && data.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(data); +} + +const messageTags = new Set([ + "user_message_chunk", "user_message", + "agent_message_chunk", "agent_message", + "agent_thought_chunk", "agent_thought", +]); + +/** + * Why a `session/update` payload is not a valid ACP v2 update, or `null` if it is. + * + * Neither the SDK nor the TS types stop a v1-only tag (e.g. `tool_call`) or a custom unprefixed + * tag from reaching a v2 client, so tests check the frames themselves: custom tags must start with + * `_`, and standard ones must match their v2 schema. Terminal bytes must be base64, `cwd` and diff + * paths absolute, and v1's private terminal and diff `_meta` keys must not reach the client. + */ +export function v2SessionUpdateViolation(update: acpV2.SessionUpdate): string | null { + const tag: unknown = update.sessionUpdate; + if (typeof tag !== "string") { + return `sessionUpdate is not a string: ${JSON.stringify(update)}`; + } + if (acpV2.SessionUpdate.isCustom(update)) { + return tag.startsWith("_") ? null : `unknown unprefixed sessionUpdate '${tag}'`; + } + if (!knownVariantGuards.some(guard => guard(update))) { + return `'${tag}' does not match its v2 schema: ${JSON.stringify(update)}`; + } + if (messageTags.has(tag) && (update as {messageId?: unknown}).messageId === "") { + return `'${tag}' has an empty messageId`; + } + if (tag === "tool_call_update") { + const meta = (update as acpV2.ToolCallUpdate)._meta; + const leaked = privateTerminalMetaKeys.filter(key => meta != null && key in meta); + if (leaked.length > 0) { + return `'${tag}' carries private terminal _meta keys: ${leaked.join(", ")}`; + } + for (const content of (update as acpV2.ToolCallUpdate).content ?? []) { + if (content.type !== "diff") continue; + const diff = content as acpV2.Diff; + if (diff._meta != null && DIFF_GIT_PATCH_META_KEY in diff._meta) { + return `'${tag}' diff carries the private '${DIFF_GIT_PATCH_META_KEY}' _meta key`; + } + const relative = diff.changes.flatMap(change => [change.path, (change as {oldPath?: unknown}).oldPath]) + .filter(changePath => changePath !== undefined && !String(changePath).startsWith("/")); + if (relative.length > 0) { + return `'${tag}' diff has relative paths: ${relative.join(", ")}`; + } + } + } + if (tag === "terminal_update") { + const {cwd, output} = update as acpV2.TerminalUpdate; + if (cwd != null && !cwd.startsWith("/")) { + return `'${tag}' has a relative cwd: ${cwd}`; + } + if (output != null && !isBase64(output.data)) { + return `'${tag}' output is not base64: ${output.data}`; + } + } + if (tag === "terminal_output_chunk" && !isBase64((update as acpV2.TerminalOutputChunk).data)) { + return `'${tag}' data is not base64: ${(update as acpV2.TerminalOutputChunk).data}`; + } + return null; +} + +const violations: string[] = []; + +/** Records a v2 `session/update` payload a test client received; see `expectConformingV2SessionUpdates`. */ +export function checkV2SessionUpdate(update: acpV2.SessionUpdate): void { + const violation = v2SessionUpdateViolation(update); + if (violation) { + violations.push(violation); + } +} + +/** Fails if any update recorded since the last call was not a valid v2 update. */ +export function expectConformingV2SessionUpdates(): void { + const found = violations.splice(0); + expect(found).toEqual([]); +} diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index df6bcb74d..81b960387 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -20,6 +20,7 @@ import {CodexEventHandler} from "../CodexEventHandler"; import type {AccountUpdatedNotification} from "../app-server/v2"; import {CodexBackgroundTerminalTasks} from "../async-tasks/CodexBackgroundTerminalTasks"; import {CodexSessionCompactions} from "../CodexSessionCompactions"; +import {CodexSessionToolCalls} from "../CodexSessionToolCalls"; import {AUTH_STATUS_UPDATE_METHOD} from "../AuthStatusMeta"; import {ClientCapabilities} from "../tool-calls/ClientCapabilities"; @@ -266,6 +267,8 @@ export interface CodexMockTestFixture extends TestFixture { sendServerRequest(method: string, params: unknown): Promise, setPermissionResponse(response: RequestPermissionResponse | Promise): void, setElicitationResponse(response: CreateElicitationResponse | Promise): void, + /** Raw `options` (e.g. `cancellationSignal`) passed to `connection.request()` calls for the given ACP method. */ + getAcpRequestOptions(method: string): any[], } /** @@ -312,8 +315,12 @@ export function createCodexMockTestFixture( // Create ACP connection with configurable permission response const acpConnectionEvents: MethodCallEvent[] = []; const acpEventHandlers: ((event: MethodCallEvent) => void)[] = []; + // Raw `request()` options (e.g. `cancellationSignal`) are stripped by `normalizeAcpConnectionEvent` + // before landing in `acpConnectionEvents`; capture them separately so tests can assert on them. + const acpRequestOptions: {method: string; options: any}[] = []; const returnValues = new Map any>(); returnValues.set('request', (args) => { + acpRequestOptions.push({method: args[0], options: args[2]}); if (args[0] === acp.methods.client.session.requestPermission) { return permissionState.response; } @@ -372,6 +379,9 @@ export function createCodexMockTestFixture( setElicitationResponse(response: CreateElicitationResponse | Promise): void { elicitationState.response = response; }, + getAcpRequestOptions(method: string): any[] { + return acpRequestOptions.filter(entry => entry.method === method).map(entry => entry.options); + }, }; } @@ -412,6 +422,8 @@ export function createTestSessionState(overrides?: Partial): Sessi const sessionId = overrides?.sessionId ?? "session-id"; return { currentTurnId: null, + interruptTurnId: null, + codexReportedRunningTurnId: null, lastTokenUsage: null, totalTokenUsage: null, promptTokenUsage: null, @@ -438,6 +450,7 @@ export function createTestSessionState(overrides?: Partial): Sessi sessionTitleSource: "unknown", compactions: new CodexSessionCompactions(), toolCallReports: new ToolCallReports(), + openToolCalls: new CodexSessionToolCalls(), subagents: new CodexSubagentEventRouter( sessionId, false, diff --git a/src/index.ts b/src/index.ts index 8fb26044b..58b0432c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ #!/usr/bin/env node -import * as acp from "@agentclientprotocol/sdk"; -import {z} from "zod"; import {startCodexConnection} from "./CodexJsonRpcConnection"; import {CodexAcpServer, type CodexProcessState} from "./CodexAcpServer"; import {createJsonStream} from "./StdUtils"; @@ -14,60 +12,8 @@ import {CodexAppServerSupervisor} from "./app-server-recovery/CodexAppServerSupe import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; import {prepareCodexHookConfig} from "./CodexHookConfig"; -import {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} from "./CodexHookTrust"; -import { - GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, - SESSION_STEERING_METHOD, -} from "./AcpExtensions"; -import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; -import { - SESSION_ARCHIVE_METHOD, - SESSION_LIST_SUBSCRIBE_METHOD, - SESSION_LIST_UNSUBSCRIBE_METHOD, - SESSION_RENAME_METHOD, - SESSION_UNARCHIVE_METHOD, - sessionArchiveParamsParser, - sessionListSubscriptionParamsParser, - sessionRenameParamsParser, -} from "./SessionIndex"; - -const emptyExtensionParamsParser = z.preprocess( - (params) => params ?? {}, - z.object({}).passthrough() -); - -const legacySetSessionModelParamsParser = z.object({ - sessionId: z.string(), - modelId: z.string(), -}).passthrough(); - -const sessionSteerParamsParser = z.object({ - sessionId: z.string(), - prompt: z.array(z.any()), -}).passthrough(); - -const goalControlParamsParser = z.discriminatedUnion("action", [ - z.object({ - sessionId: z.string(), - action: z.literal("set"), - objective: z.string().trim().min(1), - }).passthrough(), - z.object({ - sessionId: z.string(), - action: z.enum(["pause", "resume", "clear"]), - }).passthrough(), -]); - -const asyncTaskStopParamsParser = z.object({ - sessionId: z.string().trim().min(1), - asyncTaskId: z.string().trim().min(1), -}).passthrough(); - -const hooksListParamsParser = z.object({cwd: z.string().trim().min(1)}); -const hooksTrustParamsParser = z.object({ - cwd: z.string().trim().min(1), - hooks: z.array(z.object({key: z.string().min(1), currentHash: z.string().min(1)})).min(1), -}); +import {createAcpAgentRouter} from "./AcpAgentRouter"; +import type {AcpClientConnection, AcpV2Connection} from "./ACPSessionConnection"; if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); @@ -133,7 +79,7 @@ function startAcpServer() { const acpJsonStream = createJsonStream(process.stdin, process.stdout); - function createAgent(connection: acp.AgentContext): CodexAcpServer { + function createAgent(connection: AcpClientConnection | AcpV2Connection): CodexAcpServer { const appServerClient = new CodexAppServerClient(codexProcessState.connection.connection); const codexClient = new CodexAcpClient(appServerClient, hookConfig.sessionConfig, modelProvider); return new CodexAcpServer( @@ -146,54 +92,5 @@ function startAcpServer() { ); } - let codexAcpServer: CodexAcpServer | null = null; - const getAgent = (): CodexAcpServer => { - if (!codexAcpServer) { - throw acp.RequestError.internalError("ACP agent is not connected"); - } - return codexAcpServer; - }; - - acp.agent({name: packageJson.name}) - .onConnect((connection) => { - const agent = createAgent(connection.client); - codexAcpServer = agent; - connection.signal.addEventListener("abort", () => { - agent.sessionIndex.dispose(); - if (codexAcpServer === agent) { - codexAcpServer = null; - } - }); - }) - .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.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.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)) - .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(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(SESSION_LIST_SUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.subscribeList(ctx.params)) - .onRequest(SESSION_LIST_UNSUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.unsubscribeList(ctx.params)) - .connect(acpJsonStream); + createAcpAgentRouter(createAgent).connect(acpJsonStream); } diff --git a/src/mcp/McpSessionStartup.ts b/src/mcp/McpSessionStartup.ts index 76866c8d9..9b6b49819 100644 --- a/src/mcp/McpSessionStartup.ts +++ b/src/mcp/McpSessionStartup.ts @@ -3,6 +3,7 @@ import type {AcpClientConnection} from "../ACPSessionConnection"; import type {CodexAcpClient} from "../CodexAcpClient"; import {logger} from "../Logger"; import {sanitizeMcpServerName} from "../McpServerName"; +import {type AcpMcpServer, getMcpServerName} from "../McpServerConfig"; import {settledWithin} from "../StdUtils"; import {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; import type {ClientCapabilities} from "../tool-calls/ClientCapabilities"; @@ -66,7 +67,7 @@ export class McpSessionStartup { */ begin( sessionId: string, - mcpServers: Array, + mcpServers: Array, afterVersion: number, options: McpSessionStartupOptions, ): Promise | null { @@ -118,7 +119,7 @@ export class McpSessionStartup { private createPendingSession( sessionId: string, - mcpServers: Array, + mcpServers: Array, afterVersion: number, skippedServers: Array, ): PendingMcpStartupSession { @@ -202,8 +203,8 @@ export class McpSessionStartup { } } -export function getRequestedMcpServerNames(mcpServers: Array): Array { - return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name)))); +export function getRequestedMcpServerNames(mcpServers: Array): Array { + return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(getMcpServerName(server))))); } const MCP_STARTUP_AWAIT_TIMEOUT_META_KEY = "mcpStartupAwaitTimeoutMs"; diff --git a/src/permissions/CodexApprovalHandler.ts b/src/permissions/CodexApprovalHandler.ts index 16012497a..2cbe55842 100644 --- a/src/permissions/CodexApprovalHandler.ts +++ b/src/permissions/CodexApprovalHandler.ts @@ -61,7 +61,7 @@ export class CodexApprovalHandler implements ApprovalHandler { )), options: decisions.map(({option}) => option), ...requestPermissionMeta( - this.renderer.capabilities.airClient, + this.renderer.capabilities, params.networkApprovalContext ? CODEX_NETWORK_PERMISSION_TITLE : CODEX_COMMAND_PERMISSION_TITLE, params.reason, ), @@ -84,7 +84,7 @@ export class CodexApprovalHandler implements ApprovalHandler { )), options: decisions.map(({option}) => option), ...requestPermissionMeta( - this.renderer.capabilities.airClient, + this.renderer.capabilities, CODEX_FILE_CHANGE_PERMISSION_TITLE, params.reason, ), @@ -110,7 +110,7 @@ export class CodexApprovalHandler implements ApprovalHandler { )), options: permissionProfileOptions(), ...requestPermissionMeta( - this.renderer.capabilities.airClient, + this.renderer.capabilities, CODEX_ADDITIONAL_PERMISSIONS_TITLE, params.reason, ), @@ -131,7 +131,9 @@ export class CodexApprovalHandler implements ApprovalHandler { } private selectedDecision(response: acp.RequestPermissionResponse, decisions: DecisionOption[]): T | undefined { - if (response.outcome.outcome === "cancelled") return undefined; + // A v2 client may answer with a custom outcome instead of "cancelled"; only "selected" + // carries an `optionId` (ACP-ENUM-203). + if (response.outcome.outcome !== "selected") return undefined; const optionId = response.outcome.optionId; return decisions.find(({option}) => option.optionId === optionId)?.decision; } @@ -140,7 +142,7 @@ export class CodexApprovalHandler implements ApprovalHandler { permissions: RequestPermissionProfile, response: acp.RequestPermissionResponse, ): PermissionsRequestApprovalResponse { - if (response.outcome.outcome === "cancelled") return this.rejectPermissionsResponse(); + if (response.outcome.outcome !== "selected") return this.rejectPermissionsResponse(); switch (response.outcome.optionId) { case ApprovalOptionId.AllowPermissionsForTurn: return this.grantedPermissionsResponse(permissions, "turn", false); diff --git a/src/permissions/mcp.ts b/src/permissions/mcp.ts index e765df570..20e0b98b5 100644 --- a/src/permissions/mcp.ts +++ b/src/permissions/mcp.ts @@ -133,7 +133,9 @@ export function convertMcpPermissionResponse( isToolApproval: boolean, persistOptions: ReadonlySet, ): McpServerElicitationRequestResponse { - if (response.outcome.outcome === "cancelled") return cancelledResponse(); + // A v2 client may answer with a custom outcome instead of "cancelled"; only "selected" + // carries an `optionId` (ACP-ENUM-203). + if (response.outcome.outcome !== "selected") return cancelledResponse(); switch (response.outcome.optionId) { case McpApprovalOptionId.AllowSession: return persistOptions.has("session") diff --git a/src/permissions/metadata.ts b/src/permissions/metadata.ts index d0af4f63b..f45511007 100644 --- a/src/permissions/metadata.ts +++ b/src/permissions/metadata.ts @@ -1,5 +1,6 @@ import type * as acp from "@agentclientprotocol/sdk"; import {AIR_PERMISSION_KEY, airOnlyMeta} from "../AirExtension"; +import type {ClientCapabilities} from "../tool-calls/ClientCapabilities"; export const CODEX_COMMAND_PERMISSION_TITLE = "Run command?"; export const CODEX_NETWORK_PERMISSION_TITLE = "Allow network access?"; @@ -17,9 +18,18 @@ type OptionPermissionMetadata = { description: string; }; -/** Only AIR gets the permission presentation, in `_meta.jetbrains.air.permission`. */ +/** + * Private `_meta` key with the permission presentation of an ACP v2 request. The v2 renderer + * moves it into the top-level `title`/`description` fields (`AcpV2Permissions.ts`). + */ +export const PERMISSION_PROMPT_META_KEY = "permission_prompt"; + +/** + * Only AIR gets the permission presentation, in `_meta.jetbrains.air.permission`. + * On ACP v2, every client gets it as the request title and description. + */ export function requestPermissionMeta( - airClient: boolean, + capabilities: ClientCapabilities, title: string, reason?: string | null, ): Pick { @@ -29,8 +39,22 @@ export function requestPermissionMeta( title, ...(description ? {description} : {}), }; - const meta = airOnlyMeta(airClient, AIR_PERMISSION_KEY, permission); - return meta ? {_meta: meta} : {}; + const meta = { + ...airOnlyMeta(capabilities.airClient, AIR_PERMISSION_KEY, permission), + ...(capabilities.permissionPromptFields ? {[PERMISSION_PROMPT_META_KEY]: permission} : {}), + }; + return Object.keys(meta).length > 0 ? {_meta: meta} : {}; +} + +/** Reads the presentation that `requestPermissionMeta` sets for ACP v2, if present. */ +export function readPermissionMeta( + meta: acp.RequestPermissionRequest["_meta"], +): RequestPermissionMetadata | undefined { + const permission = meta?.[PERMISSION_PROMPT_META_KEY]; + if (typeof permission !== "object" || permission === null) return undefined; + const {title, description} = permission as Partial; + if (typeof title !== "string") return undefined; + return {version: 1, title, ...(typeof description === "string" ? {description} : {})}; } export function optionPermissionMeta( diff --git a/src/tool-calls/AcpToolCallRenderer.ts b/src/tool-calls/AcpToolCallRenderer.ts index 8ea0af64b..0b7af7d69 100644 --- a/src/tool-calls/AcpToolCallRenderer.ts +++ b/src/tool-calls/AcpToolCallRenderer.ts @@ -28,8 +28,8 @@ export class AcpToolCallRenderer { ...(facts.input === undefined ? {} : {rawInput: facts.input}), ...(facts.opaqueResult === undefined ? {} : {rawOutput: facts.opaqueResult}), }; - const meta = this.capabilities.airClient ? this.airMeta(facts) : this.standardMeta(facts); - if (!this.capabilities.airClient) { + const meta = this.capabilities.airToolCallContract ? this.airMeta(facts) : this.standardMeta(facts); + if (!this.capabilities.airToolCallContract) { applyStandardFields(rendered, facts.standard); const end = facts.standard?.commandEnd; if (end !== undefined) { @@ -63,7 +63,7 @@ export class AcpToolCallRenderer { ...locationsField(facts.locations), ...this.contentField(facts), }; - if (!this.capabilities.airClient) applyStandardFields(rendered, facts.standard); + if (!this.capabilities.airToolCallContract) applyStandardFields(rendered, facts.standard); return rendered as acp.ToolCallUpdate; } diff --git a/src/tool-calls/ClientCapabilities.ts b/src/tool-calls/ClientCapabilities.ts index 2e8977241..57d433bb0 100644 --- a/src/tool-calls/ClientCapabilities.ts +++ b/src/tool-calls/ClientCapabilities.ts @@ -10,6 +10,12 @@ import { /** The `_meta` key of a command output chunk. */ type TerminalOutputKey = "terminal_output" | "terminal_output_delta"; +/** + * How a file change diff carries a Git patch: not at all, in the AIR `diffPatch` extension, + * or as the patch of an ACP v2 diff. + */ +export type DiffPatchFormat = "none" | "air" | "acpV2"; + /** The AIR capabilities that change the tool call and plan reports. */ export type AirCapabilities = { /** AIR renders `rawInput` itself, so the adapter sends no display copy of the input in `content`. */ @@ -22,10 +28,13 @@ export type AirCapabilities = { type ClientCapabilityValues = { readonly airClient: boolean; + readonly airToolCallContract: boolean; readonly terminalOutput: boolean; readonly terminalOutputDelta: boolean; readonly planUpdates: boolean; readonly air: AirCapabilities; + readonly diffPatchFormat: DiffPatchFormat; + readonly permissionPromptFields: boolean; }; /** @@ -38,14 +47,19 @@ type ClientCapabilityValues = { export class ClientCapabilities { static readonly DEFAULT = new ClientCapabilities({ airClient: false, + airToolCallContract: false, terminalOutput: false, terminalOutputDelta: false, planUpdates: false, air: {rawInputRendering: false, planContentDelta: false, diffPatch: false}, + diffPatchFormat: "none", + permissionPromptFields: false, }); /** The client declares `_meta.jetbrains.air`. */ readonly airClient: boolean; + /** The client gets the tool call reports of the AIR tool call contract. Only AIR on ACP v1 does. */ + readonly airToolCallContract: boolean; /** The client declares `_meta.terminal_output`, the Zed convention for command output chunks. */ readonly terminalOutput: boolean; /** The client declares `_meta.terminal_output_delta` and appends the output chunks. */ @@ -53,26 +67,58 @@ export class ClientCapabilities { /** The client shows `plan_update`. Other clients get the plan as agent message text. */ readonly planUpdates: boolean; readonly air: AirCapabilities; + readonly diffPatchFormat: DiffPatchFormat; + /** A permission request has its own `title` and `description` (ACP v2). */ + readonly permissionPromptFields: boolean; private constructor(values: ClientCapabilityValues) { this.airClient = values.airClient; + this.airToolCallContract = values.airToolCallContract; this.terminalOutput = values.terminalOutput; this.terminalOutputDelta = values.terminalOutputDelta; this.planUpdates = values.planUpdates; this.air = values.air; + this.diffPatchFormat = values.diffPatchFormat; + this.permissionPromptFields = values.permissionPromptFields; } static from(capabilities: acp.ClientCapabilities | null | undefined): ClientCapabilities { + const airClient = isAirClient(capabilities); + const diffPatch = clientSupportsAirCapability(capabilities, AIR_DIFF_PATCH_KEY); return new ClientCapabilities({ - airClient: isAirClient(capabilities), + airClient, + airToolCallContract: airClient, terminalOutput: capabilities?._meta?.["terminal_output"] === true, terminalOutputDelta: capabilities?._meta?.["terminal_output_delta"] === true, planUpdates: capabilities?.plan != null, air: { rawInputRendering: clientSupportsAirCapability(capabilities, AIR_RAW_INPUT_RENDERING_KEY), planContentDelta: clientSupportsAirCapability(capabilities, AIR_PLAN_CONTENT_DELTA_KEY), - diffPatch: clientSupportsAirCapability(capabilities, AIR_DIFF_PATCH_KEY), + diffPatch, }, + diffPatchFormat: diffPatch ? "air" : "none", + permissionPromptFields: false, + }); + } + + /** + * The capabilities of an ACP v2 client, from the v1 view of its capabilities (`toV1ClientCapabilitiesView`). + * + * Every v2 client gets the standard tool call reports, also AIR: v2 has its own terminal and diff updates. + * A shell command streams its output into its terminal. A read, search or list command has no terminal, + * so its output goes to `content` at the end. A diff carries a Git patch. A permission request has its own title. + * Plan updates need no capability on v2. + */ + static fromV2(capabilities: acp.ClientCapabilities | null | undefined): ClientCapabilities { + return new ClientCapabilities({ + airClient: isAirClient(capabilities), + airToolCallContract: false, + terminalOutput: true, + terminalOutputDelta: false, + planUpdates: true, + air: {rawInputRendering: false, planContentDelta: false, diffPatch: false}, + diffPatchFormat: "acpV2", + permissionPromptFields: true, }); } @@ -88,7 +134,7 @@ export class ClientCapabilities { terminalOutputKey(terminal: boolean): TerminalOutputKey | null { if (this.terminalOutputDelta) return "terminal_output_delta"; if (this.terminalOutput) return terminal ? "terminal_output" : null; - return this.airClient ? null : "terminal_output_delta"; + return this.airToolCallContract ? null : "terminal_output_delta"; } } diff --git a/src/tool-calls/reporters/CommandReporter.ts b/src/tool-calls/reporters/CommandReporter.ts index 3f5ecaf28..cf8b242a0 100644 --- a/src/tool-calls/reporters/CommandReporter.ts +++ b/src/tool-calls/reporters/CommandReporter.ts @@ -37,7 +37,7 @@ export class CommandReporter { private readonly standardStreamedCommands = new Set(); started(item: CommandItem): ToolFacts { - if (usesTerminal(item)) { + if (commandUsesTerminal(item)) { this.nonTerminalCommands.delete(item.id); this.terminalCommands.add(item.id); } else { @@ -148,7 +148,7 @@ export class CommandReporter { commandEnd: { output: item.aggregatedOutput ?? "", exitCode: item.exitCode, - terminal: usesTerminal(item), + terminal: commandUsesTerminal(item), streamed: false, }, }, @@ -156,7 +156,8 @@ export class CommandReporter { } } -function usesTerminal(item: CommandItem): boolean { +/** A shell command shows a terminal. A single read, search or list command does not. */ +export function commandUsesTerminal(item: CommandItem): boolean { const action = singleAction(item.commandActions); return action === undefined || action.type === "unknown"; } @@ -215,7 +216,7 @@ function completionFacts( ...(name === undefined ? {} : {name}), status: toTerminalToolStatus(item.status), }; - if (!usesTerminal(item)) { + if (!commandUsesTerminal(item)) { const viewedFile = singleAction(item.commandActions)?.type === "read"; return output.length === 0 || viewedFile ? facts : {...facts, opaqueResult: output}; } diff --git a/src/tool-calls/reporters/FileChangeReporter.ts b/src/tool-calls/reporters/FileChangeReporter.ts index 4334ddeeb..0e3c77364 100644 --- a/src/tool-calls/reporters/FileChangeReporter.ts +++ b/src/tool-calls/reporters/FileChangeReporter.ts @@ -2,13 +2,17 @@ import type {ToolCallContent} from "@agentclientprotocol/sdk"; import {parsePatch, type StructuredPatch} from "diff"; import {AIR_DIFF_PATCH_KEY, withAirMeta} from "../../AirExtension"; import type {FileChangeRequestApprovalParams, FileUpdateChange, ThreadItem} from "../../app-server/v2"; +import {DIFF_GIT_PATCH_META_KEY} from "../../AcpV2SessionUpdate"; import { createAddedFileGitPatch, createDeletedFileGitPatch, + createEmptyFileGitPatch, + createRenameGitPatch, createUpdateGitPatch, DIFF_PATCH_MAX_BYTES, } from "../../GitPatch"; import {logger} from "../../Logger"; +import type {DiffPatchFormat} from "../ClientCapabilities"; import type {PermissionToolFacts, ToolFacts} from "../ToolFacts"; import {toTerminalToolStatus, toToolStatus} from "./ToolStatus"; @@ -22,10 +26,10 @@ const FILE_CHANGE_TITLE = "Editing files"; * An update gets one ACP diff per Codex hunk: `oldText` and `newText` hold the changed lines and the context lines * of the hunk, not the whole file. An added or a deleted file gets its whole text. A diff whose text is larger than * {@link DIFF_PATCH_MAX_BYTES} is not sent. With the AIR `diffPatch` capability, the diff is a Git patch, - * see `docs/air-extensions.md#diff-patch`. + * see `docs/air-extensions.md#diff-patch`. On ACP v2, each file gets one diff with a Git patch. */ export class FileChangeReporter { - static started(item: FileChangeItem, diffPatch: boolean): ToolFacts { + static started(item: FileChangeItem, diffPatch: DiffPatchFormat): ToolFacts { const diffs: ToolCallContent[] = []; for (const change of item.changes) { // An unparseable or a too large change has no diff. @@ -64,8 +68,10 @@ export class FileChangeReporter { } } -function createPatchContent(change: FileUpdateChange, supportsDiffPatch: boolean): ToolCallContent[] { +function createPatchContent(change: FileUpdateChange, diffPatch: DiffPatchFormat): ToolCallContent[] { + const supportsDiffPatch = diffPatch === "air"; try { + if (diffPatch === "acpV2") return createAcpV2Content(change); switch (change.kind.type) { case "add": return createWholeFileContent(change, "add", supportsDiffPatch); @@ -136,6 +142,47 @@ function createUpdateFileContent( }); } +/** + * The diff of a change for ACP v2: one diff per file, with the Git patch and the path before the change + * in `_meta`, see `toV2Diff`. A change without a valid patch has no diff. + */ +function createAcpV2Content(change: FileUpdateChange): ToolCallContent[] { + const kind = change.kind; + const oldPath = change.path; + const path = kind.type === "update" ? kind.move_path ?? change.path : change.path; + const diff = kind.type === "update" ? recoverCorruptedDiff(change.diff) : change.diff; + if (!fitsDiffLimit(diff)) { + logger.log("Skipped the diff of a file change that is too large", {path}); + return []; + } + let patch: string | null; + switch (kind.type) { + case "add": + patch = diff.length === 0 ? createEmptyFileGitPatch(path, "added") : createAddedFileGitPatch(path, diff); + break; + case "delete": + patch = diff.length === 0 ? createEmptyFileGitPatch(path, "deleted") : createDeletedFileGitPatch(path, diff); + break; + case "update": + // A pure rename has no hunks. + patch = kind.move_path !== null && diff.trim().length === 0 + ? createRenameGitPatch(oldPath, path) + : createUpdateGitPatch(oldPath, path, diff); + break; + } + if (patch === null) { + logger.log("Skipped a file change without a valid Git patch", {path}); + return []; + } + return [{ + type: "diff", + oldText: null, + newText: "", + path, + _meta: {kind: kind.type, [DIFF_GIT_PATCH_META_KEY]: {oldPath, text: patch}}, + }]; +} + /** * The old and the new text of one hunk. Each line keeps its line break, except a line that the * `\\ No newline at end of file` marker follows.